Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -61,10 +61,13 @@ import {
|
||||
import {
|
||||
EquipmentAttachments,
|
||||
equipmentAttachmentHand,
|
||||
resolveEquipmentAttachmentTransforms,
|
||||
} from "./equipmentAttachments";
|
||||
import {
|
||||
ApproximateStarterEquipment,
|
||||
StarterWeaponAttachments,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
type AvatarHand,
|
||||
} from "./starterWeapons";
|
||||
|
||||
@@ -192,6 +195,14 @@ function LoadedCharacterModel({
|
||||
}) {
|
||||
const gltf = useGLTF(resolveContentUrl(entry.model));
|
||||
const scene = useMemo(() => cloneAvatarScene(gltf.scene), [gltf.scene]);
|
||||
const equipmentAttachmentTransforms = useMemo(
|
||||
() => resolveEquipmentAttachmentTransforms(scene, entry),
|
||||
[entry, scene],
|
||||
);
|
||||
const fallbackEquipmentScale = useMemo(
|
||||
() => estimateAvatarWeaponScale(scene),
|
||||
[scene],
|
||||
);
|
||||
const mixer = useMemo(() => new AnimationMixer(scene), [scene]);
|
||||
const currentAction = useRef<AnimationAction | null>(null);
|
||||
const overrideAction = useRef<AnimationAction | null>(null);
|
||||
@@ -199,7 +210,6 @@ function LoadedCharacterModel({
|
||||
const overrideLooping = useRef(false);
|
||||
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
|
||||
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
||||
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
||||
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
||||
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
|
||||
const attackJoint = useMemo(
|
||||
@@ -233,6 +243,15 @@ function LoadedCharacterModel({
|
||||
}))] as AvatarHand[],
|
||||
[resolved.equipmentModels],
|
||||
);
|
||||
const weaponStance = useMemo(() => characterWeaponStance(
|
||||
identity.classId,
|
||||
equipment,
|
||||
resolved.equipmentModels.flatMap((itemModel) => (
|
||||
itemModel.attachment === "handLeft" || itemModel.attachment === "handRight"
|
||||
? [itemModel.itemId]
|
||||
: []
|
||||
)),
|
||||
), [equipment, identity.classId, resolved.equipmentModels]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -339,7 +358,7 @@ function LoadedCharacterModel({
|
||||
};
|
||||
|
||||
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
|
||||
selectCharacterBaseClip(gltf.animations, motion)
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0]
|
||||
@@ -347,7 +366,6 @@ function LoadedCharacterModel({
|
||||
|
||||
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||
if (overrideAction.current) return;
|
||||
if (currentMotion.current === motion) return;
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
: null;
|
||||
@@ -356,6 +374,7 @@ function LoadedCharacterModel({
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
if (!clip) return;
|
||||
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
const next = mixer.clipAction(clip, scene);
|
||||
next.enabled = true;
|
||||
@@ -462,15 +481,11 @@ function LoadedCharacterModel({
|
||||
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
||||
observedAnimationRevision.current = nextAnimationEvent.revision;
|
||||
playAnimationEvent(nextAnimationEvent);
|
||||
if (nextAnimationEvent.kind === "attack" || nextAnimationEvent.kind === "ability-release") {
|
||||
equipmentAttackPulseRef.current = nextAnimationEvent.revision;
|
||||
}
|
||||
}
|
||||
const nextAttackPulse = attackPulseRef?.current ?? 0;
|
||||
if (nextAttackPulse !== observedAttackPulse.current) {
|
||||
observedAttackPulse.current = nextAttackPulse;
|
||||
if (nextAttackPulse > 0) {
|
||||
equipmentAttackPulseRef.current = nextAttackPulse;
|
||||
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
||||
}
|
||||
}
|
||||
@@ -513,17 +528,16 @@ function LoadedCharacterModel({
|
||||
/>
|
||||
</group>
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
occupiedHands={occupiedEquipmentHands}
|
||||
root={scene}
|
||||
/>
|
||||
<EquipmentAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
fallbackScale={fallbackEquipmentScale}
|
||||
models={resolved.equipmentModels}
|
||||
root={scene}
|
||||
transforms={equipmentAttachmentTransforms}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Bone,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
Skeleton,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findAvatarSkinBone,
|
||||
resolveAvatarAttachmentPoint,
|
||||
} from "./attachmentPoints";
|
||||
|
||||
const expectVectorClose = (actual: Vector3, expected: Vector3): void => {
|
||||
expect(actual.x).toBeCloseTo(expected.x, 6);
|
||||
expect(actual.y).toBeCloseTo(expected.y, 6);
|
||||
expect(actual.z).toBeCloseTo(expected.z, 6);
|
||||
};
|
||||
|
||||
function attachmentRig() {
|
||||
const world = new Group();
|
||||
world.position.set(4, -2, 7);
|
||||
world.rotation.set(0.2, -0.35, 0.1);
|
||||
world.scale.setScalar(1.7);
|
||||
|
||||
const root = new Group();
|
||||
root.position.set(-1, 3, 2);
|
||||
root.rotation.set(-0.1, 0.25, -0.15);
|
||||
root.scale.setScalar(0.8);
|
||||
world.add(root);
|
||||
|
||||
const internalRig = new Group();
|
||||
internalRig.scale.setScalar(0.1);
|
||||
root.add(internalRig);
|
||||
const unusedBone = new Bone();
|
||||
const attachmentBone = new Bone();
|
||||
attachmentBone.position.set(2, 4, -3);
|
||||
internalRig.add(unusedBone, attachmentBone);
|
||||
|
||||
const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial());
|
||||
mesh.bind(new Skeleton([unusedBone, attachmentBone]));
|
||||
root.add(mesh);
|
||||
|
||||
return { attachmentBone, root, world };
|
||||
}
|
||||
|
||||
describe("avatar attachment-point transforms", () => {
|
||||
it("converts manifest positions into the selected skin bone", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const point = { bone: 1, position: [0.45, 0.9, -0.2] as const };
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, point, null);
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(findAvatarSkinBone(root, 1)).toBe(attachmentBone);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
const expectedWorldPosition = root.localToWorld(new Vector3(...point.position));
|
||||
const actualWorldPosition = attachmentBone.localToWorld(transform!.position.clone());
|
||||
expectVectorClose(actualWorldPosition, expectedWorldPosition);
|
||||
});
|
||||
|
||||
it("cancels internal locator scale without cancelling the avatar scale", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, attachmentBone)!;
|
||||
const portalChild = new Group();
|
||||
portalChild.scale.copy(transform.scale);
|
||||
attachmentBone.add(portalChild);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
expectVectorClose(
|
||||
portalChild.getWorldScale(new Vector3()),
|
||||
root.getWorldScale(new Vector3()),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authored hand joints at local zero after the skeleton animates", () => {
|
||||
const { attachmentBone, root } = attachmentRig();
|
||||
attachmentBone.position.set(-6, 8, 4);
|
||||
attachmentBone.rotation.set(0.7, -0.4, 0.2);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 1, position: [0.45, 0.9, -0.2] },
|
||||
null,
|
||||
{ useTargetOrigin: true },
|
||||
);
|
||||
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(transform?.position.toArray()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("falls back safely when a manifest joint is unavailable", () => {
|
||||
const { root } = attachmentRig();
|
||||
const fallback = new Group();
|
||||
root.add(fallback);
|
||||
|
||||
const withMissingJoint = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 99, position: [0.1, 0.2, 0.3] },
|
||||
fallback,
|
||||
);
|
||||
expect(withMissingJoint?.target).toBe(fallback);
|
||||
|
||||
const withoutMetadata = resolveAvatarAttachmentPoint(root, undefined, fallback);
|
||||
expect(withoutMetadata?.target).toBe(fallback);
|
||||
expect(withoutMetadata?.position.toArray()).toEqual([0, 0, 0]);
|
||||
expect(resolveAvatarAttachmentPoint(root, undefined, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SkinnedMesh, Vector3, type Object3D } from "three";
|
||||
import type { AvatarAttachmentPoint } from "./characterManifest";
|
||||
|
||||
export interface AvatarAttachmentTransform {
|
||||
readonly target: Object3D;
|
||||
readonly position: Vector3;
|
||||
readonly scale: Vector3;
|
||||
}
|
||||
|
||||
export interface AvatarAttachmentOptions {
|
||||
/** The manifest point is the authored origin of its referenced attachment joint. */
|
||||
readonly useTargetOrigin?: boolean;
|
||||
}
|
||||
|
||||
const safeScaleRatio = (rootScale: number, targetScale: number): number => (
|
||||
Number.isFinite(rootScale)
|
||||
&& Number.isFinite(targetScale)
|
||||
&& Math.abs(targetScale) > 1e-6
|
||||
? rootScale / targetScale
|
||||
: 1
|
||||
);
|
||||
|
||||
function attachmentScale(root: Object3D, target: Object3D): Vector3 {
|
||||
const rootScale = root.getWorldScale(new Vector3());
|
||||
const targetScale = target.getWorldScale(new Vector3());
|
||||
return new Vector3(
|
||||
safeScaleRatio(rootScale.x, targetScale.x),
|
||||
safeScaleRatio(rootScale.y, targetScale.y),
|
||||
safeScaleRatio(rootScale.z, targetScale.z),
|
||||
);
|
||||
}
|
||||
|
||||
export function findAvatarSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
/** Converts a manifest point from avatar-local space into its target bone. */
|
||||
export function resolveAvatarAttachmentPoint(
|
||||
root: Object3D,
|
||||
point: AvatarAttachmentPoint | undefined,
|
||||
fallbackTarget: Object3D | null,
|
||||
options: AvatarAttachmentOptions = {},
|
||||
): AvatarAttachmentTransform | null {
|
||||
const skinBone = point ? findAvatarSkinBone(root, point.bone) : null;
|
||||
const target = skinBone ?? fallbackTarget;
|
||||
if (!target) return null;
|
||||
root.updateWorldMatrix(true, true);
|
||||
const scale = attachmentScale(root, target);
|
||||
if (!point || (skinBone && options.useTargetOrigin)) {
|
||||
return { target, position: new Vector3(), scale };
|
||||
}
|
||||
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position, scale };
|
||||
}
|
||||
@@ -21,6 +21,15 @@ describe("avatar rendering integration invariants", () => {
|
||||
expect(characterModelSource).toContain("<EquipmentAttachments");
|
||||
});
|
||||
|
||||
it("uses manifest sockets while the loaded skeleton owns weapon motion", () => {
|
||||
const loadedStarterAttachment = characterModelSource.match(
|
||||
/<StarterWeaponAttachments[\s\S]*?\/>/,
|
||||
)?.[0] ?? "";
|
||||
expect(loadedStarterAttachment).toContain("entry={entry}");
|
||||
expect(loadedStarterAttachment).not.toContain("attackPulseRef=");
|
||||
expect(characterModelSource).not.toContain("equipmentAttackPulseRef");
|
||||
});
|
||||
|
||||
it("isolates each optional gear model behind its own loading and error boundary", () => {
|
||||
expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary");
|
||||
expect(equipmentAttachmentSource).toContain("<Suspense fallback={fallback}>");
|
||||
|
||||
@@ -35,7 +35,7 @@ const REQUIRED_NATIVE_IDS = [
|
||||
0, 1, 4, 5, 8, 9, 10,
|
||||
16, 17, 18, 19,
|
||||
25, 26, 27, 28,
|
||||
46,
|
||||
46, 109,
|
||||
51, 52, 53, 54,
|
||||
57, 58, 95, 124, 125,
|
||||
] as const;
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("resolveEquipmentAppearance", () => {
|
||||
|
||||
expect(resolved.models).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ itemId: 6472, attachment: "handRight" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "handLeft" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "shield" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderLeft" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderRight" }),
|
||||
]));
|
||||
@@ -39,6 +39,26 @@ describe("resolveEquipmentAppearance", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes every shield model through the dedicated shield attachment", () => {
|
||||
for (const itemId of [6447, 6630, 13245]) {
|
||||
expect(resolveEquipmentAppearance([item(itemId)], "male").models).toEqual([
|
||||
expect.objectContaining({ itemId, attachment: "shield" }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a stance-matched procedural fallback for every held model", () => {
|
||||
for (const itemId of [5243, 6447, 6448, 6469, 6472, 6630, 6631, 13245]) {
|
||||
const heldModels = resolveEquipmentAppearance([item(itemId)], "male").models
|
||||
.filter((model) => model.attachment !== "shoulderLeft" && model.attachment !== "shoulderRight");
|
||||
expect(heldModels).not.toHaveLength(0);
|
||||
for (const model of heldModels) {
|
||||
expect(model.fallbackKind).toBeTruthy();
|
||||
expect(model.weaponStance).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gendered robe textures, equipment geosets, and the actual cape skin", () => {
|
||||
const resolved = resolveEquipmentAppearance([
|
||||
item(6465),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GenderId } from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { TextureRegion } from "./characterManifest";
|
||||
|
||||
@@ -33,6 +34,8 @@ export interface EquipmentModelVisual {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly attachment: EquipmentAttachmentName;
|
||||
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
|
||||
readonly weaponStance?: CharacterWeaponStance;
|
||||
}
|
||||
|
||||
interface GenderedTextureLayer {
|
||||
@@ -67,6 +70,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Firebelcher",
|
||||
url: model("firebelcher-5243"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
5404: {
|
||||
@@ -101,7 +106,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Worn Turtle Shell Shield",
|
||||
url: model("worn-turtle-shell-shield-6447"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6448: {
|
||||
@@ -109,6 +116,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Tail Spike",
|
||||
url: model("tail-spike-6448"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "dagger",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6449: {
|
||||
@@ -188,6 +197,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Venomstrike",
|
||||
url: model("venomstrike-6469"),
|
||||
attachment: "handLeft",
|
||||
fallbackKind: "bow",
|
||||
weaponStance: "bow",
|
||||
}],
|
||||
},
|
||||
6472: {
|
||||
@@ -195,6 +206,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Stinging Viper",
|
||||
url: model("stinging-viper-6472"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6473: {
|
||||
@@ -237,7 +250,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Seedcloud Buckler",
|
||||
url: model("seedcloud-buckler-6630"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6631: {
|
||||
@@ -245,6 +260,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Living Root",
|
||||
url: model("living-root-6631"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "staff",
|
||||
weaponStance: "two-handed-left",
|
||||
}],
|
||||
},
|
||||
6632: {
|
||||
@@ -291,7 +308,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Kresh's Back",
|
||||
url: model("kreshs-back-13245"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,31 +5,47 @@ import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DoubleSide,
|
||||
Mesh,
|
||||
Object3D,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
type Material,
|
||||
} from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import type {
|
||||
EquipmentAttachmentName,
|
||||
EquipmentModelVisual,
|
||||
} from "./equipmentAppearance";
|
||||
import {
|
||||
resolveAvatarAttachmentPoint,
|
||||
type AvatarAttachmentTransform,
|
||||
} from "./attachmentPoints";
|
||||
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
||||
import {
|
||||
avatarWeaponSocketRotation,
|
||||
findAvatarHandBone,
|
||||
StarterWeaponAttachments,
|
||||
findAvatarShieldBone,
|
||||
StarterWeaponVisual,
|
||||
type AvatarHand,
|
||||
type StarterWeaponRotation,
|
||||
} from "./starterWeapons";
|
||||
|
||||
const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.freeze([
|
||||
"handLeft",
|
||||
"handRight",
|
||||
"shield",
|
||||
"shoulderLeft",
|
||||
"shoulderRight",
|
||||
]);
|
||||
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
|
||||
export type EquipmentAttachmentTransforms = Readonly<
|
||||
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
|
||||
>;
|
||||
|
||||
export function equipmentAttachmentHand(
|
||||
attachment: EquipmentAttachmentName,
|
||||
): AvatarHand | null {
|
||||
@@ -58,23 +74,14 @@ function cloneEquipmentScene(source: Object3D, name: string): Object3D {
|
||||
return scene;
|
||||
}
|
||||
|
||||
function findSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
function fallbackTarget(
|
||||
root: Object3D,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): Object3D | null {
|
||||
if (attachment === "handLeft" || attachment === "shield") {
|
||||
return findAvatarHandBone(root, "left");
|
||||
return attachment === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: findAvatarHandBone(root, "left");
|
||||
}
|
||||
if (attachment === "handRight") return findAvatarHandBone(root, "right");
|
||||
return root.getObjectByName(
|
||||
@@ -82,43 +89,42 @@ function fallbackTarget(
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function attachmentTransform(
|
||||
/** Snapshots all authored attachment offsets once, before animation begins. */
|
||||
export function resolveEquipmentAttachmentTransforms(
|
||||
root: Object3D,
|
||||
entry: AvatarManifestEntry,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): { target: Object3D; position: Vector3 } | null {
|
||||
const point = entry.attachmentPoints?.[attachment];
|
||||
const target = point
|
||||
? findSkinBone(root, point.bone) ?? fallbackTarget(root, attachment)
|
||||
: fallbackTarget(root, attachment);
|
||||
if (!target) return null;
|
||||
if (!point) return { target, position: new Vector3() };
|
||||
|
||||
root.updateWorldMatrix(true, true);
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position };
|
||||
): EquipmentAttachmentTransforms {
|
||||
const transforms: Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>> = {};
|
||||
for (const attachment of EQUIPMENT_ATTACHMENT_NAMES) {
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
entry.attachmentPoints?.[attachment],
|
||||
fallbackTarget(root, attachment),
|
||||
{
|
||||
useTargetOrigin: attachment === "handLeft"
|
||||
|| attachment === "handRight"
|
||||
|| attachment === "shield",
|
||||
},
|
||||
);
|
||||
if (transform) transforms[attachment] = transform;
|
||||
}
|
||||
return transforms;
|
||||
}
|
||||
|
||||
function AttachedEquipmentModel({
|
||||
entry,
|
||||
itemModel,
|
||||
root,
|
||||
transform,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly root: Object3D;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const gltf = useGLTF(resolveContentUrl(itemModel.url));
|
||||
const scene = useMemo(
|
||||
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
|
||||
[gltf.scene, itemModel.attachment, itemModel.itemId],
|
||||
);
|
||||
const transform = useMemo(
|
||||
() => attachmentTransform(root, entry, itemModel.attachment),
|
||||
[entry, itemModel.attachment, root],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
const materials = new Set<Material>();
|
||||
scene.traverse((node) => {
|
||||
@@ -130,52 +136,109 @@ function AttachedEquipmentModel({
|
||||
materials.forEach((material) => material.dispose());
|
||||
}, [scene]);
|
||||
|
||||
if (!transform) return null;
|
||||
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
|
||||
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
|
||||
: NO_ROTATION;
|
||||
|
||||
return createPortal(
|
||||
<primitive object={scene} position={transform.position} />,
|
||||
<group
|
||||
position={transform.position}
|
||||
rotation={socketRotation}
|
||||
scale={transform.scale}
|
||||
>
|
||||
<primitive object={scene} />
|
||||
</group>,
|
||||
transform.target,
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentAttachments({
|
||||
attackPulseRef,
|
||||
classId,
|
||||
function EquipmentModelFallback({
|
||||
entry,
|
||||
models,
|
||||
root,
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
}: {
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const hand = equipmentAttachmentHand(itemModel.attachment);
|
||||
if (!hand || !itemModel.fallbackKind || !itemModel.weaponStance) return null;
|
||||
const socketRotation = avatarWeaponSocketRotation(
|
||||
entry,
|
||||
itemModel.fallbackKind,
|
||||
itemModel.weaponStance,
|
||||
);
|
||||
return createPortal(
|
||||
<group
|
||||
position={transform.position}
|
||||
rotation={socketRotation}
|
||||
scale={transform.scale}
|
||||
>
|
||||
<StarterWeaponVisual
|
||||
hand={hand}
|
||||
kind={itemModel.fallbackKind}
|
||||
name={`fallback-equipment-${itemModel.itemId}-${itemModel.attachment}`}
|
||||
scale={fallbackScale}
|
||||
/>
|
||||
</group>,
|
||||
transform.target,
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedEquipmentAttachment({
|
||||
entry,
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const fallback = (
|
||||
<EquipmentModelFallback
|
||||
entry={entry}
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AvatarErrorBoundary
|
||||
fallback={fallback}
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
|
||||
</Suspense>
|
||||
</AvatarErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentAttachments({
|
||||
entry,
|
||||
fallbackScale,
|
||||
models,
|
||||
transforms,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly models: readonly EquipmentModelVisual[];
|
||||
readonly root: Object3D;
|
||||
readonly transforms: EquipmentAttachmentTransforms;
|
||||
}): ReactNode {
|
||||
return models.map((itemModel) => {
|
||||
const hand = equipmentAttachmentHand(itemModel.attachment);
|
||||
const fallback = hand
|
||||
? (
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={attackPulseRef}
|
||||
classId={classId}
|
||||
onlyHands={[hand]}
|
||||
root={root}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<AvatarErrorBoundary
|
||||
const transform = transforms[itemModel.attachment];
|
||||
return transform ? (
|
||||
<ResolvedEquipmentAttachment
|
||||
key={`${itemModel.itemId}:${itemModel.attachment}`}
|
||||
fallback={fallback}
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel
|
||||
entry={entry}
|
||||
itemModel={itemModel}
|
||||
root={root}
|
||||
/>
|
||||
</Suspense>
|
||||
</AvatarErrorBoundary>
|
||||
);
|
||||
entry={entry}
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AvatarManifest } from "./characterManifest";
|
||||
|
||||
interface RunewakerGlbDocument {
|
||||
readonly animations?: readonly { readonly name?: string }[];
|
||||
readonly nodes?: readonly { readonly name?: string }[];
|
||||
}
|
||||
|
||||
function readGlbDocument(glb: Buffer): RunewakerGlbDocument {
|
||||
expect(glb.readUInt32LE(0)).toBe(0x46546c67);
|
||||
const jsonLength = glb.readUInt32LE(12);
|
||||
return JSON.parse(
|
||||
glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd(),
|
||||
) as RunewakerGlbDocument;
|
||||
}
|
||||
|
||||
const manifestFile = path.resolve("public/assets/characters/runewaker-v1/manifest.json");
|
||||
|
||||
describe("runewaker-v1 playable avatar pack", () => {
|
||||
@@ -32,6 +45,17 @@ describe("runewaker-v1 playable avatar pack", () => {
|
||||
});
|
||||
const model = path.resolve("public", entry.model.replace(/^\//, ""));
|
||||
expect((await stat(model)).size).toBe(entry.bytes);
|
||||
const document = readGlbDocument(await readFile(model));
|
||||
const animationNames = new Set(
|
||||
document.animations?.map((animation) => animation.name ?? "") ?? [],
|
||||
);
|
||||
for (const animationName of ["Idle - 1h_idle", "Idle - 2h_idle", "Idle - bow_idle"]) {
|
||||
expect(animationNames.has(animationName), `${entry.model} ${animationName}`).toBe(true);
|
||||
}
|
||||
const nodeNames = new Set(document.nodes?.map((node) => node.name ?? "") ?? []);
|
||||
for (const nodeName of ["p_left_hand", "p_right_hand", "p_hand_shield"]) {
|
||||
expect(nodeNames.has(nodeName), `${entry.model} ${nodeName}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Group, Object3D } from "three";
|
||||
import { Euler, Group, Object3D, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||
import {
|
||||
STARTER_WEAPON_LOADOUTS,
|
||||
approximateStarterWeaponGripRotation,
|
||||
avatarWeaponSocketRotation,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
findAvatarHandBone,
|
||||
findAvatarHandBones,
|
||||
findAvatarShieldBone,
|
||||
sampleStarterWeaponStrike,
|
||||
starterWeaponItemsForAvailableHands,
|
||||
starterWeaponLoadout,
|
||||
starterWeaponModelRotation,
|
||||
starterWeaponStance,
|
||||
} from "./starterWeapons";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import { equipmentAttachmentHand } from "./equipmentAttachments";
|
||||
|
||||
interface RawGlbNode {
|
||||
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
|
||||
expect(equipmentAttachmentHand("shield")).toBe("left");
|
||||
expect(equipmentAttachmentHand("shoulderLeft")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the native ready stance that matches each loadout", () => {
|
||||
expect(starterWeaponStance("warrior")).toBe("one-handed");
|
||||
expect(starterWeaponStance("rogue")).toBe("one-handed");
|
||||
expect(starterWeaponStance("hunter")).toBe("bow");
|
||||
expect(starterWeaponStance("priest")).toBe("two-handed-left");
|
||||
expect(starterWeaponStance("death-knight")).toBe("two-handed");
|
||||
expect(starterWeaponStance("rom-priest")).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("lets visible equipped weapons override the class starter stance", () => {
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 6472, weaponType: "dagger" },
|
||||
], [6472])).toBe("one-handed");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "two-hand", itemId: 6631, weaponType: "staff" },
|
||||
], [6631])).toBe("two-handed-left");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "ranged", itemId: 6469, weaponType: "bow" },
|
||||
], [6469])).toBe("bow");
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 9999, weaponType: "dagger" },
|
||||
], [])).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("normalizes procedural equipment to the native WoW item axes", () => {
|
||||
const rotated = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => new Vector3(...direction).applyEuler(new Euler(...starterWeaponModelRotation(kind)));
|
||||
|
||||
expect(rotated("sword", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("dagger", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("mace", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("staff", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 0, -1]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(starterWeaponModelRotation("shield")).toEqual([0, 0, 0]);
|
||||
|
||||
const fallbackDirection = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => rotated(kind, direction).applyEuler(
|
||||
new Euler(...approximateStarterWeaponGripRotation(kind)),
|
||||
);
|
||||
expect(fallbackDirection("sword", [0, -1, 0]).distanceTo(new Vector3(0, -1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("staff", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("bow", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it("adapts canonical weapons to RuneWaker prop-socket axes", () => {
|
||||
const entry = {
|
||||
rendererStrategy: { kind: "runewaker-modular" },
|
||||
} as AvatarManifestEntry;
|
||||
const socketDirection = (
|
||||
kind: Parameters<typeof avatarWeaponSocketRotation>[1],
|
||||
stance: Parameters<typeof avatarWeaponSocketRotation>[2],
|
||||
) => new Vector3(1, 0, 0).applyEuler(
|
||||
new Euler(...avatarWeaponSocketRotation(entry, kind, stance)),
|
||||
);
|
||||
|
||||
expect(socketDirection("sword", "one-handed").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("bow", "bow").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("staff", "two-handed-left").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("sword", "two-handed").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("shield", "one-handed").distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(avatarWeaponSocketRotation(undefined, "sword", "one-handed"))
|
||||
.toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatar hand-bone discovery", () => {
|
||||
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
|
||||
expect(findAvatarHandBone(root, "right")).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritizes explicit RuneWaker equipment locators over generic hand bones", () => {
|
||||
const root = new Group();
|
||||
const genericLeft = new Group();
|
||||
genericLeft.name = "Bip01 L Hand";
|
||||
const genericRight = new Group();
|
||||
genericRight.name = "Bip01 R Hand";
|
||||
const leftPivot = new Group();
|
||||
leftPivot.name = "p_left_hand";
|
||||
const rightPivot = new Group();
|
||||
rightPivot.name = "p_right_hand";
|
||||
const shieldPivot = new Group();
|
||||
shieldPivot.name = "p_hand_shield";
|
||||
root.add(genericLeft, genericRight, leftPivot, rightPivot, shieldPivot);
|
||||
|
||||
expect(findAvatarHandBone(root, "left")).toBe(leftPivot);
|
||||
expect(findAvatarHandBone(root, "right")).toBe(rightPivot);
|
||||
expect(findAvatarShieldBone(root)).toBe(shieldPivot);
|
||||
});
|
||||
|
||||
it("cancels inherited locator scale for portal children", () => {
|
||||
const root = new Group();
|
||||
const scaledRig = new Group();
|
||||
scaledRig.scale.setScalar(0.1);
|
||||
const pivot = new Group();
|
||||
root.add(scaledRig);
|
||||
scaledRig.add(pivot);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, pivot);
|
||||
expect(transform?.scale.toArray()).toEqual([10, 10, 10]);
|
||||
});
|
||||
|
||||
it("estimates equipment scale in avatar-local space and ignores parent transforms", () => {
|
||||
const actor = new Group();
|
||||
actor.position.set(20, 8, -12);
|
||||
@@ -143,7 +265,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
});
|
||||
|
||||
it("resolves both hands in every shipped race/gender character rig", () => {
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1"].map((version) => path.join(
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join(
|
||||
projectDirectory,
|
||||
"public",
|
||||
"assets",
|
||||
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
const entries = manifestPaths.flatMap((manifestPath) => (
|
||||
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
|
||||
));
|
||||
expect(entries).toHaveLength(45);
|
||||
expect(entries).toHaveLength(50);
|
||||
const resolvedLeftNames = new Set<string>();
|
||||
const resolvedRightNames = new Set<string>();
|
||||
const unresolved: string[] = [];
|
||||
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
|
||||
// accidentally hardcodes one id cannot satisfy this diversity check.
|
||||
expect(resolvedLeftNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedRightNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedLeftNames).toContain("p_left_hand");
|
||||
expect(resolvedRightNames).toContain("p_right_hand");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
|
||||
+228
-27
@@ -7,6 +7,10 @@ import {
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import type { AvatarAttachmentPoint, AvatarManifestEntry } from "./characterManifest";
|
||||
|
||||
export type AvatarHand = "left" | "right";
|
||||
|
||||
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
|
||||
export interface StarterWeaponLoadout {
|
||||
readonly classId: ClassId;
|
||||
readonly items: readonly StarterWeaponItem[];
|
||||
readonly stance: StarterWeaponStance;
|
||||
}
|
||||
|
||||
export type StarterWeaponStance = CharacterWeaponStance;
|
||||
|
||||
const item = (
|
||||
id: string,
|
||||
kind: StarterWeaponKind,
|
||||
@@ -45,6 +52,7 @@ const item = (
|
||||
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
|
||||
warrior: Object.freeze({
|
||||
classId: "warrior",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("warrior-sword", "sword", "right", 1.04),
|
||||
item("warrior-shield", "shield", "left", 0.96),
|
||||
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
paladin: Object.freeze({
|
||||
classId: "paladin",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("paladin-mace", "mace", "right", 1.04),
|
||||
item("paladin-shield", "shield", "left", 0.98),
|
||||
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
hunter: Object.freeze({
|
||||
classId: "hunter",
|
||||
stance: "bow",
|
||||
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
|
||||
}),
|
||||
rogue: Object.freeze({
|
||||
classId: "rogue",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("rogue-main-dagger", "dagger", "right", 0.94),
|
||||
item("rogue-off-dagger", "dagger", "left", 0.94),
|
||||
@@ -70,14 +81,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
priest: Object.freeze({
|
||||
classId: "priest",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
|
||||
}),
|
||||
"death-knight": Object.freeze({
|
||||
classId: "death-knight",
|
||||
stance: "two-handed",
|
||||
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
|
||||
}),
|
||||
shaman: Object.freeze({
|
||||
classId: "shaman",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("shaman-mace", "mace", "right", 1),
|
||||
item("shaman-shield", "shield", "left", 0.9),
|
||||
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
mage: Object.freeze({
|
||||
classId: "mage",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
|
||||
}),
|
||||
warlock: Object.freeze({
|
||||
classId: "warlock",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
|
||||
}),
|
||||
druid: Object.freeze({
|
||||
classId: "druid",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]),
|
||||
}),
|
||||
});
|
||||
@@ -103,6 +120,7 @@ export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoad
|
||||
return [characterClass.id, Object.freeze({
|
||||
classId: characterClass.id,
|
||||
items: source.items,
|
||||
stance: source.stance,
|
||||
})];
|
||||
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
|
||||
);
|
||||
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
|
||||
return STARTER_WEAPON_LOADOUTS[classId];
|
||||
}
|
||||
|
||||
export function starterWeaponStance(classId: ClassId): StarterWeaponStance {
|
||||
return starterWeaponLoadout(classId).stance;
|
||||
}
|
||||
|
||||
type VisibleWeaponItem = Pick<InventoryItem, "inventorySlot" | "itemId" | "weaponType">;
|
||||
|
||||
/** Selects a ready pose from the weapon models that are actually being rendered. */
|
||||
export function characterWeaponStance(
|
||||
classId: ClassId,
|
||||
equipment: readonly VisibleWeaponItem[] = [],
|
||||
visibleWeaponItemIds: readonly number[] = [],
|
||||
): StarterWeaponStance {
|
||||
const visibleIds = new Set(visibleWeaponItemIds);
|
||||
const visibleWeapons = equipment.filter((equipped) => (
|
||||
equipped.weaponType && visibleIds.has(equipped.itemId)
|
||||
));
|
||||
if (visibleWeapons.some((equipped) => equipped.weaponType === "bow")) return "bow";
|
||||
const weapon = visibleWeapons.find((equipped) => equipped.inventorySlot !== "ranged")
|
||||
?? visibleWeapons[0];
|
||||
if (!weapon) return starterWeaponStance(classId);
|
||||
if (weapon.weaponType === "staff") return "two-handed-left";
|
||||
return weapon.inventorySlot === "two-hand" ? "two-handed" : "one-handed";
|
||||
}
|
||||
|
||||
export function starterWeaponItemsForAvailableHands(
|
||||
classId: ClassId,
|
||||
occupiedHands: readonly AvatarHand[] = [],
|
||||
@@ -124,6 +166,13 @@ const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freez
|
||||
right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_HAND_PIVOT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
|
||||
left: /^p_left_hand$/i,
|
||||
right: /^p_right_hand$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_SHIELD_PIVOT_PATTERN = /^p_hand_shield$/i;
|
||||
|
||||
const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
|
||||
left: Object.freeze([
|
||||
/(?:^|[_ .-])L Hand$/i,
|
||||
@@ -142,11 +191,19 @@ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Obje
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds a playable-character hand without relying on exporter-specific numeric
|
||||
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender
|
||||
* export, and their shared parent is the animated hand bone.
|
||||
* Finds a playable-character grip without relying on exporter-specific numeric
|
||||
* bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
|
||||
* named finger pivots otherwise identify their shared animated hand bone.
|
||||
*/
|
||||
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_HAND_PIVOT_PATTERN[hand].test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
if (explicitPivot) return explicitPivot;
|
||||
|
||||
const votes = new Map<Object3D, number>();
|
||||
root.traverse((node) => {
|
||||
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return;
|
||||
@@ -171,6 +228,16 @@ export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D |
|
||||
return namedFallback;
|
||||
}
|
||||
|
||||
export function findAvatarShieldBone(root: Object3D): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_SHIELD_PIVOT_PATTERN.test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
return explicitPivot ?? findAvatarHandBone(root, "left");
|
||||
}
|
||||
|
||||
export interface AvatarHandBones {
|
||||
readonly left: Object3D | null;
|
||||
readonly right: Object3D | null;
|
||||
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
|
||||
export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
|
||||
|
||||
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
const STARTER_WEAPON_MODEL_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([Math.PI / 2, 0, -Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const APPROXIMATE_WEAPON_GRIP_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const RUNEWAKER_ONE_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
-Math.PI / 2,
|
||||
0,
|
||||
]);
|
||||
const RUNEWAKER_TWO_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
]);
|
||||
|
||||
/** Normalizes procedural geometry to the same local axes as native item GLBs. */
|
||||
export function starterWeaponModelRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return STARTER_WEAPON_MODEL_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Synthetic hand pose used only while the animated character rig is absent. */
|
||||
export function approximateStarterWeaponGripRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return APPROXIMATE_WEAPON_GRIP_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Adapts the canonical WoW +X item frame to a rig's authored prop socket. */
|
||||
export function avatarWeaponSocketRotation(
|
||||
entry: AvatarManifestEntry | undefined,
|
||||
kind: StarterWeaponKind,
|
||||
stance: StarterWeaponStance,
|
||||
): StarterWeaponRotation {
|
||||
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
|
||||
return ZERO_ROTATION;
|
||||
}
|
||||
return stance === "two-handed" || stance === "two-handed-left"
|
||||
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
|
||||
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
|
||||
}
|
||||
|
||||
const STRIKE_DURATION_SECONDS = 0.54;
|
||||
|
||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
@@ -374,16 +502,16 @@ function BowVisual() {
|
||||
|
||||
function ShieldVisual() {
|
||||
return (
|
||||
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}>
|
||||
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||
<group name="starter-shield-geometry" position={[0, 0, -0.08]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
|
||||
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]}>
|
||||
<torusGeometry args={[0.3, 0.035, 6, 16]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
|
||||
</mesh>
|
||||
@@ -392,12 +520,22 @@ function ShieldVisual() {
|
||||
}
|
||||
|
||||
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
|
||||
if (kind === "bow") return <BowVisual />;
|
||||
if (kind === "dagger") return <DaggerVisual />;
|
||||
if (kind === "mace") return <MaceVisual />;
|
||||
if (kind === "shield") return <ShieldVisual />;
|
||||
if (kind === "staff") return <StaffVisual />;
|
||||
return <SwordVisual />;
|
||||
const visual = kind === "bow"
|
||||
? <BowVisual />
|
||||
: kind === "dagger"
|
||||
? <DaggerVisual />
|
||||
: kind === "mace"
|
||||
? <MaceVisual />
|
||||
: kind === "shield"
|
||||
? <ShieldVisual />
|
||||
: kind === "staff"
|
||||
? <StaffVisual />
|
||||
: <SwordVisual />;
|
||||
return (
|
||||
<group rotation={starterWeaponModelRotation(kind)}>
|
||||
{visual}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StarterWeaponVisualProps {
|
||||
@@ -449,35 +587,88 @@ export function StarterWeaponVisual({
|
||||
interface AttachedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly attachmentScale: Vector3;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly position: Vector3;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
readonly target: Object3D;
|
||||
}
|
||||
|
||||
interface ResolvedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly fallbackTarget: Object3D | null;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly point?: AvatarAttachmentPoint;
|
||||
readonly root: Object3D;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
}
|
||||
|
||||
function AttachedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
attachmentScale,
|
||||
item: equipped,
|
||||
position,
|
||||
scale,
|
||||
socketRotation,
|
||||
target,
|
||||
}: AttachedStarterWeaponProps) {
|
||||
return createPortal(
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
hand={equipped.hand}
|
||||
kind={equipped.kind}
|
||||
name={`starter-equipment-${equipped.id}`}
|
||||
scale={scale * equipped.scale}
|
||||
/>,
|
||||
<group position={position} rotation={socketRotation} scale={attachmentScale}>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
hand={equipped.hand}
|
||||
kind={equipped.kind}
|
||||
name={`starter-equipment-${equipped.id}`}
|
||||
scale={scale * equipped.scale}
|
||||
/>
|
||||
</group>,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
fallbackTarget,
|
||||
item: equipped,
|
||||
point,
|
||||
root,
|
||||
scale,
|
||||
socketRotation,
|
||||
}: ResolvedStarterWeaponProps): ReactNode {
|
||||
const transform = useMemo(
|
||||
() => resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
point,
|
||||
fallbackTarget,
|
||||
{ useTargetOrigin: true },
|
||||
),
|
||||
[fallbackTarget, point, root],
|
||||
);
|
||||
return transform ? (
|
||||
<AttachedStarterWeapon
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
attachmentScale={transform.scale}
|
||||
item={equipped}
|
||||
position={transform.position}
|
||||
scale={scale}
|
||||
socketRotation={socketRotation}
|
||||
target={transform.target}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export interface StarterWeaponAttachmentsProps {
|
||||
readonly attackPulse?: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry?: AvatarManifestEntry;
|
||||
readonly occupiedHands?: readonly AvatarHand[];
|
||||
readonly onlyHands?: readonly AvatarHand[];
|
||||
readonly root: Object3D;
|
||||
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
|
||||
attackPulse = 0,
|
||||
attackPulseRef,
|
||||
classId,
|
||||
entry,
|
||||
occupiedHands = [],
|
||||
onlyHands,
|
||||
root,
|
||||
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
|
||||
[hands, root, scale],
|
||||
);
|
||||
const allowedHands = onlyHands ? new Set(onlyHands) : null;
|
||||
const stance = starterWeaponStance(classId);
|
||||
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
|
||||
.filter((item) => !allowedHands || allowedHands.has(item.hand));
|
||||
return items.map((equipped) => {
|
||||
const target = hands[equipped.hand];
|
||||
return target ? (
|
||||
<AttachedStarterWeapon
|
||||
const attachmentName = equipped.kind === "shield"
|
||||
? "shield"
|
||||
: equipped.hand === "left" ? "handLeft" : "handRight";
|
||||
const fallbackTarget = equipped.kind === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: hands[equipped.hand];
|
||||
return (
|
||||
<ResolvedStarterWeapon
|
||||
key={equipped.id}
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
fallbackTarget={fallbackTarget}
|
||||
item={equipped}
|
||||
point={entry?.attachmentPoints?.[attachmentName]}
|
||||
root={root}
|
||||
scale={equipmentScale}
|
||||
target={target}
|
||||
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
|
||||
<group
|
||||
key={equipped.id}
|
||||
position={handPositions[equipped.hand]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
|
||||
>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
describe("combatSimulationTimerShouldRun", () => {
|
||||
it("runs during active local gameplay", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
paused: false,
|
||||
companionOpen: false,
|
||||
documentHidden: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ paused: true, companionOpen: false, documentHidden: false },
|
||||
{ paused: false, companionOpen: true, documentHidden: false },
|
||||
{ paused: false, companionOpen: false, documentHidden: true },
|
||||
])("stops inactive local gameplay for %o", (state) => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
...state,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves shared-online simulation in every local lifecycle state", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: true,
|
||||
paused: true,
|
||||
companionOpen: true,
|
||||
documentHidden: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
+84
-60
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
onlineSessionIsActive,
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -42,73 +44,55 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = `${character.level}:${character.experience}:${JSON.stringify(character.talentRanks)}`;
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = `${state.level}:${state.xp}:${JSON.stringify(state.talentRanks)}`;
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
useShellStore.getState().saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
const characterId = character.id;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = JSON.stringify(useCombatStore.getState().actionBindings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = JSON.stringify(state.actionBindings);
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode === "manastorm") return;
|
||||
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
// serializing each collection, without doing five full-store callbacks
|
||||
// and JSON.stringify work for every 100 ms simulation notification.
|
||||
if (
|
||||
state.level !== previousState.level
|
||||
|| state.xp !== previousState.xp
|
||||
|| state.talentRanks !== previousState.talentRanks
|
||||
) {
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastInventory = JSON.stringify(character.inventory);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextInventory = JSON.stringify(state.inventory);
|
||||
if (nextInventory === lastInventory) return;
|
||||
lastInventory = nextInventory;
|
||||
useShellStore.getState().saveActiveInventory(state.inventory);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode !== "manastorm") {
|
||||
shell.saveActiveActionBindings(state.actionBindings);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastEquipment = JSON.stringify(character.equipment ?? {});
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextEquipment = JSON.stringify(state.equipment);
|
||||
if (nextEquipment === lastEquipment) return;
|
||||
lastEquipment = nextEquipment;
|
||||
useShellStore.getState().saveActiveEquipment(state.equipment);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastSettings = JSON.stringify(useCombatStore.getState().settings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextSettings = JSON.stringify(state.settings);
|
||||
if (nextSettings === lastSettings) return;
|
||||
lastSettings = nextSettings;
|
||||
useShellStore.getState().saveActiveSettings(state.settings);
|
||||
if (state.inventory !== previousState.inventory) {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
}
|
||||
if (state.settings !== previousState.settings) {
|
||||
shell.saveActiveSettings(state.settings);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
let timer: number | null = null;
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
@@ -138,8 +122,48 @@ export function CombatBridge() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
};
|
||||
|
||||
const reconcileTimer = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shouldRun = combatSimulationTimerShouldRun({
|
||||
sharedOnline: onlineSessionIsActive(),
|
||||
paused: game.paused,
|
||||
companionOpen: game.companionOpen,
|
||||
documentHidden: document.hidden,
|
||||
});
|
||||
if (shouldRun && timer === null) {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
// leak into the first simulation delta.
|
||||
previous = performance.now();
|
||||
timer = window.setInterval(advance, 100);
|
||||
} else if (!shouldRun && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
state.paused !== previousState.paused
|
||||
|| state.companionOpen !== previousState.companionOpen
|
||||
) {
|
||||
reconcileTimer();
|
||||
}
|
||||
});
|
||||
// Online runtime configuration is intentionally module-local; the session
|
||||
// store notification gives the bridge a reactive point at which to
|
||||
// reconcile while preserving continuous shared-online simulation.
|
||||
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
||||
document.addEventListener("visibilitychange", reconcileTimer);
|
||||
reconcileTimer();
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
unsubscribeGame();
|
||||
unsubscribeOnlineSession();
|
||||
document.removeEventListener("visibilitychange", reconcileTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("filters classes without changing their canonical definitions", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(60, ["mage", "rom-priest"]);
|
||||
|
||||
expect(builds.map((build) => `${build.classId}:${build.role}`)).toEqual([
|
||||
"mage:damage",
|
||||
"rom-priest:healer",
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BASE_CLASS_IDS,
|
||||
CLASSES,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
type CoaClassId,
|
||||
type RomClassId,
|
||||
} from "../../app/characterCatalog";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityAiHints,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
runtimeAllocateTalentPoint,
|
||||
runtimeNormalizeTalentRanks,
|
||||
type RuntimeTalentRanks,
|
||||
} from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadTalentUiCatalog,
|
||||
type TalentUiCatalog,
|
||||
type TalentUiNode,
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
> = Object.freeze({
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
warlock: { damage: "Affliction" },
|
||||
druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" },
|
||||
});
|
||||
|
||||
type AbilityAiCategory = AbilityAiHints["categories"][number];
|
||||
|
||||
/**
|
||||
* The large gaps keep the primary responsibility of a role ahead of merely
|
||||
* having more utility buttons. These are only selection weights; allocation
|
||||
* legality and point costs remain entirely owned by the runtime talent system.
|
||||
*/
|
||||
const COA_ROLE_CATEGORY_WEIGHTS: Readonly<
|
||||
Record<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
|
||||
> = Object.freeze({
|
||||
tank: Object.freeze({
|
||||
taunt: 1_000,
|
||||
mitigation: 500,
|
||||
control: 50,
|
||||
interrupt: 50,
|
||||
heal: 20,
|
||||
"emergency-heal": 20,
|
||||
dispel: 15,
|
||||
debuff: 10,
|
||||
execute: 10,
|
||||
damage: 10,
|
||||
}),
|
||||
healer: Object.freeze({
|
||||
"emergency-heal": 800,
|
||||
"area-heal": 700,
|
||||
heal: 500,
|
||||
mitigation: 100,
|
||||
dispel: 80,
|
||||
resurrection: 60,
|
||||
control: 20,
|
||||
interrupt: 15,
|
||||
debuff: 5,
|
||||
damage: 2,
|
||||
}),
|
||||
damage: Object.freeze({
|
||||
execute: 200,
|
||||
damage: 100,
|
||||
debuff: 40,
|
||||
interrupt: 30,
|
||||
control: 20,
|
||||
mitigation: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
interface CoaRoleTextPattern {
|
||||
readonly pattern: RegExp;
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
// CoA specialization trees contain many passive nodes with no executable
|
||||
// ability (and consequently no AbilityAiHints). Catalog text is a stable
|
||||
// secondary signal for those nodes; executable ability hints still contribute
|
||||
// directly whenever the runtime catalog provides them.
|
||||
const COA_ROLE_TEXT_PATTERNS: Readonly<Record<PartyRole, readonly CoaRoleTextPattern[]>> = Object.freeze({
|
||||
tank: Object.freeze([
|
||||
{ pattern: /\b(?:taunt|threat)\b/i, weight: 300 },
|
||||
{ pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 },
|
||||
{ pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 },
|
||||
{ pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 },
|
||||
{ pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 },
|
||||
{ pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 },
|
||||
{ pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 },
|
||||
{ pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 },
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
{ pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 },
|
||||
{ pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 },
|
||||
{ pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 },
|
||||
{ pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 },
|
||||
{ pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 },
|
||||
]),
|
||||
damage: Object.freeze([
|
||||
{ pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 },
|
||||
{ pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 },
|
||||
{ pattern: /\b(?:enemy|enemies)\b/i, weight: 5 },
|
||||
]),
|
||||
});
|
||||
|
||||
function isBaseClassId(classId: ClassId): classId is BaseClassId {
|
||||
return BASE_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isCoaClassId(classId: ClassId): classId is CoaClassId {
|
||||
return COA_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isRomClassId(classId: ClassId): classId is RomClassId {
|
||||
return ROM_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string {
|
||||
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, level),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
secondaryClassId: null,
|
||||
role,
|
||||
level,
|
||||
inventory: EMPTY_INVENTORY,
|
||||
equipment: EMPTY_EQUIPMENT,
|
||||
gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function wowBuild(
|
||||
classId: BaseClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
});
|
||||
}
|
||||
|
||||
function isClassTree(tree: TalentUiTree): boolean {
|
||||
return tree.name.trim().toLowerCase() === "class";
|
||||
}
|
||||
|
||||
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
||||
const mutable = new Map<string, AbilityDefinition[]>();
|
||||
for (const ability of abilitiesForClass(classId)) {
|
||||
if (ability.talentEntryId === undefined) continue;
|
||||
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
||||
const existing = mutable.get(nodeId);
|
||||
if (existing) existing.push(ability);
|
||||
else mutable.set(nodeId, [ability]);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const weights = COA_ROLE_CATEGORY_WEIGHTS[role];
|
||||
const categoryScore = hints.categories.reduce(
|
||||
(total, category) => total + (weights[category] ?? 0),
|
||||
0,
|
||||
);
|
||||
if (categoryScore <= 0) return 0;
|
||||
const priority = Number.isFinite(hints.priority)
|
||||
? Math.max(0, Math.min(100, hints.priority ?? 0))
|
||||
: 0;
|
||||
return categoryScore + priority;
|
||||
}
|
||||
|
||||
function coaNodeRoleScore(
|
||||
nodeId: string,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return (abilitiesByNode.get(nodeId) ?? []).reduce(
|
||||
(highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number {
|
||||
const searchable = `${node.name} ${node.description}`;
|
||||
return COA_ROLE_TEXT_PATTERNS[role].reduce(
|
||||
(total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeSelectionScore(
|
||||
node: TalentUiNode,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
|
||||
}
|
||||
|
||||
function coaTreeRoleScore(
|
||||
tree: TalentUiTree,
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return catalog.nodes.reduce((total, node) => (
|
||||
node.treeId === tree.id
|
||||
? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank
|
||||
: total
|
||||
), 0);
|
||||
}
|
||||
|
||||
function preferredCoaTree(
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): TalentUiTree {
|
||||
const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree));
|
||||
const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees;
|
||||
const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index]));
|
||||
const selected = [...candidates].sort((left, right) => (
|
||||
coaTreeRoleScore(right, catalog, role, abilitiesByNode)
|
||||
- coaTreeRoleScore(left, catalog, role, abilitiesByNode)
|
||||
|| (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0)
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function orderedCoaAllocationCandidates(
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): readonly TalentUiNode[] {
|
||||
const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id));
|
||||
const treePriority = (node: TalentUiNode) => (
|
||||
node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2
|
||||
);
|
||||
return catalog.nodes
|
||||
.filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId))
|
||||
.sort((left, right) => (
|
||||
treePriority(left) - treePriority(right)
|
||||
|| coaNodeSelectionScore(right, role, abilitiesByNode)
|
||||
- coaNodeSelectionScore(left, role, abilitiesByNode)
|
||||
|| left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
function allocateCoaTalentRanks(
|
||||
classId: CoaClassId,
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): RuntimeTalentRanks {
|
||||
const candidates = orderedCoaAllocationCandidates(
|
||||
catalog,
|
||||
preferredTree,
|
||||
role,
|
||||
abilitiesByNode,
|
||||
);
|
||||
const successfulRequests: Record<string, number> = {};
|
||||
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
|
||||
|
||||
// A successful request is never repeated, even if normalization later omits
|
||||
// a free node. This keeps zero-cost prerequisite layouts finite while failed
|
||||
// gated nodes remain eligible after lower rows have been filled.
|
||||
while (true) {
|
||||
let allocated = false;
|
||||
for (const node of candidates) {
|
||||
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
|
||||
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
|
||||
if (!result.ok) continue;
|
||||
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
|
||||
ranks = result.ranks;
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (!allocated) break;
|
||||
}
|
||||
|
||||
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
|
||||
}
|
||||
|
||||
function coaBuild(
|
||||
classId: CoaClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog,
|
||||
): BenchmarkBuildDefinition {
|
||||
const abilitiesByNode = coaNodeAbilities(classId);
|
||||
const orientation = preferredCoaTree(catalog, role, abilitiesByNode);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: orientation.name,
|
||||
talentRanks: allocateCoaTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
orientation,
|
||||
role,
|
||||
level,
|
||||
abilitiesByNode,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function romBuild(
|
||||
classId: RomClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
const coaCatalogs = new Map<CoaClassId, TalentUiCatalog>(await Promise.all(
|
||||
selectedCoaClassIds.map(async (classId) => [
|
||||
classId,
|
||||
await loadTalentUiCatalog(classId),
|
||||
] as const),
|
||||
));
|
||||
|
||||
const builds: BenchmarkBuildDefinition[] = [];
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
builds.push(coaBuild(classId, role, safeLevel, catalog));
|
||||
} else if (isRomClassId(classId)) {
|
||||
builds.push(romBuild(classId, role, safeLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import type { CastFailureReason, CombatState } from "../combatStore";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { getMobPosition } from "../mobRuntimeRegistry";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v1";
|
||||
|
||||
export interface BenchmarkPolicyDecision {
|
||||
readonly made: boolean;
|
||||
readonly cast: boolean;
|
||||
readonly resourceBlocked: boolean;
|
||||
readonly invalidFailures: number;
|
||||
}
|
||||
|
||||
interface FriendlyTarget {
|
||||
readonly id: string;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
readonly missingPercent: number;
|
||||
}
|
||||
|
||||
interface ScoredAbility {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly score: number;
|
||||
readonly friendlyTarget: FriendlyTarget | null;
|
||||
}
|
||||
|
||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||
"already-casting",
|
||||
"global-cooldown",
|
||||
"cooldown",
|
||||
"not-enough-resource",
|
||||
"not-enough-runes",
|
||||
"condition-not-met",
|
||||
"player-controlled",
|
||||
"school-locked",
|
||||
]);
|
||||
|
||||
function healthTarget(id: string, health: number, maxHealth: number): FriendlyTarget {
|
||||
return {
|
||||
id,
|
||||
health,
|
||||
maxHealth,
|
||||
missingPercent: maxHealth > 0 ? Math.max(0, 1 - health / maxHealth) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyTargets(combat: CombatState, party: PartyState): readonly FriendlyTarget[] {
|
||||
return [
|
||||
healthTarget(PLAYER_AGGRO_ID, combat.health, combat.maxHealth),
|
||||
...party.members.map((member) => healthTarget(member.id, member.health, member.maxHealth)),
|
||||
]
|
||||
.filter((target) => target.health > 0)
|
||||
.sort((left, right) => right.missingPercent - left.missingPercent || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
|
||||
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
|
||||
const learnedRank = ability.talentEntryId === undefined
|
||||
? undefined
|
||||
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
||||
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return ranked;
|
||||
}
|
||||
|
||||
function isAreaAbility(ability: AbilityDefinition): boolean {
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
return targeting.shape !== "unit"
|
||||
|| (targeting.maximumTargets ?? 1) > 1
|
||||
|| (targeting.radius ?? ability.radius ?? 0) > 0;
|
||||
}
|
||||
|
||||
function throughputWeight(ability: AbilityDefinition): number {
|
||||
let weight = 0;
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
weight += Math.max(0, effect.basePoints ?? 0) * 0.01;
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
weight += Math.max(0, effect.baseCoefficient + effect.perComboCoefficient * 3) * 10;
|
||||
} else if (effect.kind === "execute") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
}
|
||||
}
|
||||
return Math.min(40, weight);
|
||||
}
|
||||
|
||||
function scoreAbility(
|
||||
ability: AbilityDefinition,
|
||||
role: PartyRole,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
combat: CombatState,
|
||||
targets: readonly FriendlyTarget[],
|
||||
): ScoredAbility | null {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const categories = new Set(hints.categories);
|
||||
const injured = targets.filter((target) => target.missingPercent > 0.005);
|
||||
const lowest = targets[0] ?? null;
|
||||
const area = isAreaAbility(ability);
|
||||
let score = hints.priority ?? 0;
|
||||
let friendlyTarget: FriendlyTarget | null = null;
|
||||
|
||||
if (role === "healer") {
|
||||
const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
|
||||
const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield");
|
||||
if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null;
|
||||
friendlyTarget = ability.target === "self"
|
||||
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
|
||||
: lowest;
|
||||
score += canHeal ? 100 : 70;
|
||||
score += friendlyTarget.missingPercent * 180;
|
||||
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
|
||||
if (categories.has("area-heal") || area) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
|
||||
} else {
|
||||
const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff");
|
||||
if (role === "damage" && !hostileDamage) return null;
|
||||
if (role === "tank" && !hostileDamage && !categories.has("mitigation") && !categories.has("taunt")) return null;
|
||||
if (hostileDamage) score += 100 + throughputWeight(ability);
|
||||
if (categories.has("execute")) {
|
||||
const target = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
|
||||
const executeActive = Boolean(target && target.health / Math.max(1, target.maxHealth) <= 0.2);
|
||||
score += executeActive ? 120 : -180;
|
||||
}
|
||||
if (scenario.hostileTargets > 1) score += area ? 60 + scenario.hostileTargets * 4 : -15;
|
||||
else if (area) score -= 15;
|
||||
if (ability.effects.some((effect) => effect.kind === "dot")
|
||||
&& combat.effects.some((effect) => effect.sourceAbilityId === ability.id && effect.targetId === combat.selectedTargetId)) {
|
||||
score -= 250;
|
||||
}
|
||||
if (role === "tank") {
|
||||
const playerHealth = combat.health / Math.max(1, combat.maxHealth);
|
||||
if (categories.has("mitigation")) score += playerHealth < 0.8 ? 150 : 20;
|
||||
if (categories.has("taunt")) {
|
||||
const looseMobs = Object.values(combat.mobs).filter((mob) => !mob.dead && mob.targetActorId !== PLAYER_AGGRO_ID).length;
|
||||
score += looseMobs > 0 ? 220 : -80;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ability, score, friendlyTarget };
|
||||
}
|
||||
|
||||
/** A deterministic, shared baseline policy. It is intentionally not an optimizer. */
|
||||
export class BenchmarkController {
|
||||
#nextDecisionAt = 0;
|
||||
|
||||
act(now: number, role: PartyRole, scenario: BenchmarkScenarioDefinition, moving: boolean): BenchmarkPolicyDecision {
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
if (
|
||||
now < this.#nextDecisionAt
|
||||
|| combat.health <= 0
|
||||
|| combat.activeCast
|
||||
|| combat.globalCooldownEndsAt > now
|
||||
|| combat.controlledUntil > now
|
||||
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
||||
|
||||
const targets = friendlyTargets(combat, party);
|
||||
const candidates = combat.abilities
|
||||
.map((ability) => learnedAbility(ability, combat))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
|
||||
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
||||
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||
|
||||
let resourceBlocked = false;
|
||||
let invalidFailures = 0;
|
||||
for (const candidate of candidates) {
|
||||
const { ability, friendlyTarget } = candidate;
|
||||
let targetActorId: string | null | undefined;
|
||||
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||
if (friendlyTarget?.id === PLAYER_AGGRO_ID) {
|
||||
party.selectSelf();
|
||||
targetActorId = null;
|
||||
} else if (friendlyTarget) {
|
||||
party.selectMember(friendlyTarget.id);
|
||||
targetActorId = friendlyTarget.id;
|
||||
}
|
||||
} else if (ability.target === "hostile" || ability.target === "any") {
|
||||
party.clearSelection();
|
||||
useCombatStore.getState().selectFriendlyActor(null);
|
||||
targetActorId = combat.selectedTargetId;
|
||||
}
|
||||
const hostilePosition = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const result = useCombatStore.getState().castAbility(ability.id, {
|
||||
now,
|
||||
origin: combat.playerPosition,
|
||||
targetActorId,
|
||||
...(targeting.shape === "ground"
|
||||
? { groundPosition: ability.target === "hostile" || ability.target === "any"
|
||||
? hostilePosition ?? combat.playerPosition
|
||||
: combat.playerPosition }
|
||||
: {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
this.#nextDecisionAt = now + 50;
|
||||
return { made: true, cast: true, resourceBlocked: false, invalidFailures };
|
||||
}
|
||||
if (result.reason === "not-enough-resource" || result.reason === "not-enough-runes") resourceBlocked = true;
|
||||
if (result.reason && !TRANSIENT_FAILURES.has(result.reason)) invalidFailures += 1;
|
||||
}
|
||||
|
||||
this.#nextDecisionAt = now + 1_500;
|
||||
return { made: true, cast: false, resourceBlocked, invalidFailures };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { resourceProfileForClass, type ResourceType } from "../abilityCatalog";
|
||||
import type { CombatState } from "../combatStore";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "../combatEvents";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import type {
|
||||
BenchmarkAbilityMetrics,
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkResourceMetrics,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
castsStarted: number;
|
||||
castsCompleted: number;
|
||||
interrupts: number;
|
||||
rawDamage: number;
|
||||
effectiveDamage: number;
|
||||
effectiveHealing: number;
|
||||
overheal: number;
|
||||
absorbsApplied: number;
|
||||
}
|
||||
|
||||
interface MutableResourceMetrics {
|
||||
starting: number;
|
||||
ending: number;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRecorderSample {
|
||||
readonly now: number;
|
||||
readonly deltaMs: number;
|
||||
readonly combat: CombatState;
|
||||
readonly party: PartyState;
|
||||
readonly movedDistance: number;
|
||||
readonly moving: boolean;
|
||||
readonly stackCompliant: boolean | null;
|
||||
readonly heldMobs: number;
|
||||
readonly totalThreatMobs: number;
|
||||
}
|
||||
|
||||
const emptyAbilityMetrics = (): MutableAbilityMetrics => ({
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
interrupts: 0,
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
});
|
||||
|
||||
function percent(numerator: number, denominator: number): number {
|
||||
return denominator > 0 ? numerator / denominator * 100 : 0;
|
||||
}
|
||||
|
||||
/** Collects the uncapped event stream and fixed-step state samples for one run. */
|
||||
export class BenchmarkRecorder {
|
||||
readonly #build: BenchmarkBuildDefinition;
|
||||
readonly #scenario: BenchmarkScenarioDefinition;
|
||||
readonly #seed: number;
|
||||
readonly #startedAt: number;
|
||||
readonly #priorityTargetId: string;
|
||||
readonly #playerSources = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #friendlyTargets = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #eventCounts = new Map<string, number>();
|
||||
readonly #abilities = new Map<string, MutableAbilityMetrics>();
|
||||
readonly #resources = new Map<ResourceType, MutableResourceMetrics>();
|
||||
readonly #alive = new Map<string, boolean>();
|
||||
readonly #unsubscribe: () => void;
|
||||
|
||||
#rawDamage = 0;
|
||||
#effectiveDamage = 0;
|
||||
#priorityTargetDamage = 0;
|
||||
#effectiveHealing = 0;
|
||||
#rawHealing = 0;
|
||||
#overheal = 0;
|
||||
#absorbsApplied = 0;
|
||||
#absorbsConsumed = 0;
|
||||
#rawDamageTaken = 0;
|
||||
#effectiveDamageTaken = 0;
|
||||
#incomingFriendlyDamageEvents = 0;
|
||||
#incomingAttacks = 0;
|
||||
#avoidedAttacks = 0;
|
||||
#deaths = 0;
|
||||
#minimumPlayerHealthPercent = 100;
|
||||
#minimumPartyHealthPercent = 100;
|
||||
#castsStarted = 0;
|
||||
#castsCompleted = 0;
|
||||
#movementInterrupts = 0;
|
||||
#invalidCasts = 0;
|
||||
#distanceMoved = 0;
|
||||
#movementTimeMs = 0;
|
||||
#stackCompliantMs = 0;
|
||||
#stackObservedMs = 0;
|
||||
#threatHeldMobMs = 0;
|
||||
#threatObservedMobMs = 0;
|
||||
#timeToResourceFailureMs: number | null = null;
|
||||
#timeToFirstDeathMs: number | null = null;
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
seed: number,
|
||||
startedAt: number,
|
||||
priorityTargetId: string,
|
||||
) {
|
||||
this.#build = build;
|
||||
this.#scenario = scenario;
|
||||
this.#seed = seed;
|
||||
this.#startedAt = startedAt;
|
||||
this.#priorityTargetId = priorityTargetId;
|
||||
this.#unsubscribe = subscribeCombatEvents((event) => this.#recordEvent(event));
|
||||
}
|
||||
|
||||
trackPlayerSummons(combat: CombatState): void {
|
||||
for (const summon of combat.summons) {
|
||||
if (summon.ownerActorId === PLAYER_AGGRO_ID) this.#playerSources.add(summon.id);
|
||||
}
|
||||
}
|
||||
|
||||
recordInvalidCast(): void {
|
||||
this.#invalidCasts += 1;
|
||||
}
|
||||
|
||||
markResourceFailure(now: number): void {
|
||||
if (this.#timeToResourceFailureMs === null) {
|
||||
this.#timeToResourceFailureMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#unsubscribe();
|
||||
}
|
||||
|
||||
sample(sample: BenchmarkRecorderSample): void {
|
||||
const { combat, party, deltaMs } = sample;
|
||||
this.captureGearStats(combat);
|
||||
this.trackPlayerSummons(combat);
|
||||
this.#distanceMoved += Math.max(0, sample.movedDistance);
|
||||
if (sample.moving) this.#movementTimeMs += deltaMs;
|
||||
if (sample.stackCompliant !== null) {
|
||||
this.#stackObservedMs += deltaMs;
|
||||
if (sample.stackCompliant) this.#stackCompliantMs += deltaMs;
|
||||
}
|
||||
if (sample.totalThreatMobs > 0) {
|
||||
this.#threatObservedMobMs += sample.totalThreatMobs * deltaMs;
|
||||
this.#threatHeldMobMs += sample.heldMobs * deltaMs;
|
||||
}
|
||||
|
||||
const playerHealthPercent = percent(combat.health, combat.maxHealth);
|
||||
this.#minimumPlayerHealthPercent = Math.min(this.#minimumPlayerHealthPercent, playerHealthPercent);
|
||||
const livingPartyHealth = party.members
|
||||
.filter((member) => member.health > 0)
|
||||
.map((member) => percent(member.health, member.maxHealth));
|
||||
if (livingPartyHealth.length) {
|
||||
this.#minimumPartyHealthPercent = Math.min(this.#minimumPartyHealthPercent, ...livingPartyHealth);
|
||||
} else if (party.members.length) {
|
||||
this.#minimumPartyHealthPercent = 0;
|
||||
}
|
||||
|
||||
this.#observeAlive(PLAYER_AGGRO_ID, combat.health > 0, sample.now);
|
||||
for (const member of party.members) {
|
||||
this.#friendlyTargets.add(member.id);
|
||||
this.#observeAlive(member.id, member.health > 0, sample.now);
|
||||
}
|
||||
|
||||
const activeResource = resourceProfileForClass(combat.classId).type;
|
||||
const resourceIds = new Set<ResourceType>([
|
||||
activeResource,
|
||||
...Object.keys(combat.maxResourcePools) as ResourceType[],
|
||||
]);
|
||||
for (const id of resourceIds) {
|
||||
const maximum = id === activeResource
|
||||
? combat.maxResource
|
||||
: combat.maxResourcePools[id] ?? 0;
|
||||
const current = id === activeResource
|
||||
? combat.resource
|
||||
: combat.resourcePools[id] ?? 0;
|
||||
const metrics = this.#resources.get(id) ?? {
|
||||
starting: current,
|
||||
ending: current,
|
||||
minimum: current,
|
||||
maximum,
|
||||
timeBelowTenPercentMs: 0,
|
||||
};
|
||||
metrics.ending = current;
|
||||
metrics.minimum = Math.min(metrics.minimum, current);
|
||||
metrics.maximum = Math.max(metrics.maximum, maximum);
|
||||
if (maximum > 0 && current / maximum < 0.1) metrics.timeBelowTenPercentMs += deltaMs;
|
||||
this.#resources.set(id, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
finish(simulatedDurationMs: number, completed: boolean): BenchmarkRunReport {
|
||||
this.dispose();
|
||||
const seconds = Math.max(0.001, simulatedDurationMs / 1_000);
|
||||
const reasons: string[] = [];
|
||||
if (simulatedDurationMs <= 0) reasons.push("No combat time was simulated.");
|
||||
if (this.#castsCompleted <= 0 && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The policy completed no abilities and dealt no damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("damage") && this.#build.role === "damage" && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The damage build produced no effective damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("healer") && this.#build.role === "healer" && this.#incomingFriendlyDamageEvents <= 0 && this.#rawHealing <= 0) {
|
||||
reasons.push("The healing scenario produced neither incoming pressure nor healing.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("tank") && this.#build.role === "tank" && this.#incomingAttacks <= 0) {
|
||||
reasons.push("The tanking scenario produced no incoming player attacks.");
|
||||
}
|
||||
|
||||
const metrics: BenchmarkRunMetrics = Object.freeze({
|
||||
rawDamage: this.#rawDamage,
|
||||
effectiveDamage: this.#effectiveDamage,
|
||||
priorityTargetDamage: this.#priorityTargetDamage,
|
||||
effectiveHealing: this.#effectiveHealing,
|
||||
rawHealing: this.#rawHealing,
|
||||
overheal: this.#overheal,
|
||||
absorbsApplied: this.#absorbsApplied,
|
||||
absorbsConsumed: this.#absorbsConsumed,
|
||||
rawDamageTaken: this.#rawDamageTaken,
|
||||
effectiveDamageTaken: this.#effectiveDamageTaken,
|
||||
incomingAttacks: this.#incomingAttacks,
|
||||
avoidedAttacks: this.#avoidedAttacks,
|
||||
deaths: this.#deaths,
|
||||
minimumPlayerHealthPercent: this.#minimumPlayerHealthPercent,
|
||||
minimumPartyHealthPercent: this.#minimumPartyHealthPercent,
|
||||
castsStarted: this.#castsStarted,
|
||||
castsCompleted: this.#castsCompleted,
|
||||
movementInterrupts: this.#movementInterrupts,
|
||||
invalidCasts: this.#invalidCasts,
|
||||
distanceMoved: this.#distanceMoved,
|
||||
movementTimeMs: this.#movementTimeMs,
|
||||
stackCompliancePercent: this.#stackObservedMs > 0
|
||||
? percent(this.#stackCompliantMs, this.#stackObservedMs)
|
||||
: null,
|
||||
threatControlPercent: this.#threatObservedMobMs > 0
|
||||
? percent(this.#threatHeldMobMs, this.#threatObservedMobMs)
|
||||
: null,
|
||||
timeToResourceFailureMs: this.#timeToResourceFailureMs,
|
||||
timeToFirstDeathMs: this.#timeToFirstDeathMs,
|
||||
dps: this.#effectiveDamage / seconds,
|
||||
hps: this.#effectiveHealing / seconds,
|
||||
dtps: this.#effectiveDamageTaken / seconds,
|
||||
overhealPercent: percent(this.#overheal, this.#rawHealing),
|
||||
mitigationPercent: percent(this.#rawDamageTaken - this.#effectiveDamageTaken, this.#rawDamageTaken),
|
||||
avoidancePercent: percent(this.#avoidedAttacks, this.#incomingAttacks),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
seed: this.#seed,
|
||||
build: this.#build,
|
||||
scenario: this.#scenario,
|
||||
simulatedDurationMs,
|
||||
completed,
|
||||
metrics,
|
||||
resources: this.#resourceReport(),
|
||||
abilities: this.#abilityReport(),
|
||||
eventCounts: Object.freeze(Object.fromEntries([...this.#eventCounts].sort(([left], [right]) => left.localeCompare(right)))),
|
||||
validation: Object.freeze({ valid: reasons.length === 0, reasons: Object.freeze(reasons) }),
|
||||
gearStats: this.#lastGearStats,
|
||||
});
|
||||
}
|
||||
|
||||
#lastGearStats: BenchmarkRunReport["gearStats"] = {} as BenchmarkRunReport["gearStats"];
|
||||
|
||||
captureGearStats(combat: CombatState): void {
|
||||
this.#lastGearStats = combat.gearStats;
|
||||
}
|
||||
|
||||
#observeAlive(id: string, alive: boolean, now: number): void {
|
||||
const previous = this.#alive.get(id);
|
||||
this.#alive.set(id, alive);
|
||||
if (previous === undefined || !previous || alive) return;
|
||||
this.#deaths += 1;
|
||||
if (this.#timeToFirstDeathMs === null) this.#timeToFirstDeathMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
|
||||
#ability(id: string): MutableAbilityMetrics {
|
||||
const metrics = this.#abilities.get(id) ?? emptyAbilityMetrics();
|
||||
this.#abilities.set(id, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
#recordEvent(event: StructuredCombatEvent): void {
|
||||
this.#eventCounts.set(event.kind, (this.#eventCounts.get(event.kind) ?? 0) + 1);
|
||||
const playerSource = this.#playerSources.has(event.sourceActorId)
|
||||
|| event.sourceActorId.startsWith("player-summon-")
|
||||
|| event.sourceActorId.startsWith("player-triggered-summon-")
|
||||
|| event.sourceActorId.startsWith("player-pet-")
|
||||
|| event.sourceActorId.startsWith("player-utility-")
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:summon-`)
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:totem-`);
|
||||
const ability = playerSource && event.abilityId ? this.#ability(event.abilityId) : null;
|
||||
if (playerSource && event.kind === "damage" && !this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#rawDamage += event.rawAmount;
|
||||
this.#effectiveDamage += event.effectiveAmount;
|
||||
if (event.targetActorId === this.#priorityTargetId) this.#priorityTargetDamage += event.effectiveAmount;
|
||||
if (ability) {
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "healing") {
|
||||
this.#rawHealing += event.rawAmount;
|
||||
this.#effectiveHealing += event.effectiveAmount;
|
||||
this.#overheal += event.overheal;
|
||||
if (ability) {
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "absorb") {
|
||||
const amount = Math.max(event.effectiveAmount, event.absorbed);
|
||||
this.#absorbsApplied += amount;
|
||||
if (ability) ability.absorbsApplied += amount;
|
||||
}
|
||||
if (event.kind === "damage" && !playerSource && this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#incomingFriendlyDamageEvents += 1;
|
||||
this.#absorbsConsumed += event.absorbed;
|
||||
if (event.targetActorId === PLAYER_AGGRO_ID) {
|
||||
this.#incomingAttacks += 1;
|
||||
if (["miss", "dodge", "parry", "resist"].includes(event.outcome ?? "")) this.#avoidedAttacks += 1;
|
||||
this.#rawDamageTaken += event.rawAmount;
|
||||
this.#effectiveDamageTaken += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "cast" && ability) {
|
||||
if (event.outcome === "started") {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
} else if (event.outcome === "completed") {
|
||||
if (ability.castsStarted <= ability.castsCompleted) {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
}
|
||||
ability.castsCompleted += 1;
|
||||
this.#castsCompleted += 1;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "interrupt") {
|
||||
if (ability) ability.interrupts += 1;
|
||||
if (event.outcome === "movement") this.#movementInterrupts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#resourceReport(): readonly BenchmarkResourceMetrics[] {
|
||||
return Object.freeze([...this.#resources]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([id, metrics]) => Object.freeze({ id, ...metrics })));
|
||||
}
|
||||
|
||||
#abilityReport(): readonly BenchmarkAbilityMetrics[] {
|
||||
return Object.freeze([...this.#abilities]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([abilityId, metrics]) => Object.freeze({ abilityId, ...metrics })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "../itemStats";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
import {
|
||||
aggregateBenchmarkMatrix,
|
||||
benchmarkQuantile,
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
} from "./benchmarkReport";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
|
||||
const build: BenchmarkBuildDefinition = {
|
||||
id: "mage-burst",
|
||||
source: "wow",
|
||||
classId: "mage",
|
||||
secondaryClassId: null,
|
||||
role: "damage",
|
||||
level: 60,
|
||||
orientation: 'spell, "burst"',
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
gearPresetId: "level-60-baseline",
|
||||
};
|
||||
|
||||
const defaultMetrics: BenchmarkRunMetrics = {
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
priorityTargetDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
rawHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
absorbsConsumed: 0,
|
||||
rawDamageTaken: 0,
|
||||
effectiveDamageTaken: 0,
|
||||
incomingAttacks: 0,
|
||||
avoidedAttacks: 0,
|
||||
deaths: 0,
|
||||
minimumPlayerHealthPercent: 100,
|
||||
minimumPartyHealthPercent: 100,
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
movementInterrupts: 0,
|
||||
invalidCasts: 0,
|
||||
distanceMoved: 0,
|
||||
movementTimeMs: 0,
|
||||
stackCompliancePercent: null,
|
||||
threatControlPercent: null,
|
||||
timeToResourceFailureMs: null,
|
||||
timeToFirstDeathMs: null,
|
||||
dps: 0,
|
||||
hps: 0,
|
||||
dtps: 0,
|
||||
overhealPercent: 0,
|
||||
mitigationPercent: 0,
|
||||
avoidancePercent: 0,
|
||||
};
|
||||
|
||||
function run(
|
||||
seed: number,
|
||||
metrics: Partial<BenchmarkRunMetrics>,
|
||||
valid = true,
|
||||
): BenchmarkRunReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: `mage-burst:${seed}`,
|
||||
policyId: "baseline-v1",
|
||||
seed,
|
||||
build,
|
||||
scenario,
|
||||
simulatedDurationMs: scenario.durationMs,
|
||||
completed: seed !== 2,
|
||||
metrics: { ...defaultMetrics, ...metrics },
|
||||
resources: [],
|
||||
abilities: [],
|
||||
eventCounts: {},
|
||||
validation: { valid, reasons: valid ? [] : ["fixture invalid"] },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
};
|
||||
}
|
||||
|
||||
function matrix(): BenchmarkMatrixReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
appVersion: "test",
|
||||
cohort: {
|
||||
level: 60,
|
||||
gearPresetId: "level-60-baseline",
|
||||
seeds: [1, 2, 3, 4],
|
||||
durationScale: 1,
|
||||
policyId: "baseline-v1",
|
||||
},
|
||||
scenarios: [scenario],
|
||||
builds: [build],
|
||||
runs: [
|
||||
run(1, { dps: 10, priorityTargetDamage: 100 }),
|
||||
run(2, { dps: 20, priorityTargetDamage: 200 }),
|
||||
run(3, { dps: 30, priorityTargetDamage: 300 }),
|
||||
run(4, { dps: 999, priorityTargetDamage: 9_999 }, false),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("benchmark reporting", () => {
|
||||
it("uses deterministic interpolated quantiles", () => {
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.1)).toBe(12);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.5)).toBe(20);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.9)).toBe(28);
|
||||
expect(benchmarkQuantile([null], 0.5)).toBeNull();
|
||||
expect(() => benchmarkQuantile([1], 1.1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("aggregates validated runs and retains null-event counts", () => {
|
||||
const summary = aggregateBenchmarkMatrix(matrix());
|
||||
const [row] = summary.rows;
|
||||
|
||||
expect(summary).toMatchObject({ runCount: 4, validRunCount: 3, invalidRunCount: 1 });
|
||||
expect(row).toMatchObject({
|
||||
runCount: 4,
|
||||
validRunCount: 3,
|
||||
invalidRunCount: 1,
|
||||
completedRunCount: 3,
|
||||
});
|
||||
expect(row.metrics.dps).toEqual({
|
||||
count: 3,
|
||||
nullCount: 0,
|
||||
p10: 12,
|
||||
median: 20,
|
||||
p90: 28,
|
||||
});
|
||||
expect(row.metrics.timeToFirstDeathMs).toEqual({
|
||||
count: 0,
|
||||
nullCount: 3,
|
||||
p10: null,
|
||||
median: null,
|
||||
p90: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes complete metric rows as newline-clean CSV", () => {
|
||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||
|
||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
||||
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
||||
expect(csv.endsWith("\n")).toBe(true);
|
||||
expect(csv.endsWith("\n\n")).toBe(false);
|
||||
expect(csv).not.toContain("\r");
|
||||
});
|
||||
|
||||
it("renders separate role metrics instead of a composite ranking", () => {
|
||||
const markdown = renderBenchmarkMatrixMarkdown(matrix());
|
||||
|
||||
expect(markdown).toContain("## Damage");
|
||||
expect(markdown).toContain("DPS: 20 [12–28]");
|
||||
expect(markdown).toContain("1 invalid run(s) are excluded");
|
||||
expect(markdown).toContain("does not calculate a composite score");
|
||||
expect(markdown.endsWith("\n")).toBe(true);
|
||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type {
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioFamily,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_METRIC_KEYS = Object.freeze([
|
||||
"rawDamage",
|
||||
"effectiveDamage",
|
||||
"priorityTargetDamage",
|
||||
"effectiveHealing",
|
||||
"rawHealing",
|
||||
"overheal",
|
||||
"absorbsApplied",
|
||||
"absorbsConsumed",
|
||||
"rawDamageTaken",
|
||||
"effectiveDamageTaken",
|
||||
"incomingAttacks",
|
||||
"avoidedAttacks",
|
||||
"deaths",
|
||||
"minimumPlayerHealthPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"castsStarted",
|
||||
"castsCompleted",
|
||||
"movementInterrupts",
|
||||
"invalidCasts",
|
||||
"distanceMoved",
|
||||
"movementTimeMs",
|
||||
"stackCompliancePercent",
|
||||
"threatControlPercent",
|
||||
"timeToResourceFailureMs",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
"hps",
|
||||
"dtps",
|
||||
"overhealPercent",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
] as const satisfies readonly (keyof BenchmarkRunMetrics)[]);
|
||||
|
||||
export type BenchmarkMetricKey = (typeof BENCHMARK_METRIC_KEYS)[number];
|
||||
|
||||
export interface BenchmarkDistribution {
|
||||
/** Number of validated runs with a finite observation for this metric. */
|
||||
readonly count: number;
|
||||
/** Validated runs where the metric was null or non-finite. */
|
||||
readonly nullCount: number;
|
||||
readonly median: number | null;
|
||||
readonly p10: number | null;
|
||||
readonly p90: number | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummaryRow {
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioVersion: number;
|
||||
readonly scenarioName: string;
|
||||
readonly scenarioFamily: BenchmarkScenarioFamily;
|
||||
readonly buildId: string;
|
||||
readonly source: BenchmarkRunReport["build"]["source"];
|
||||
readonly classId: BenchmarkRunReport["build"]["classId"];
|
||||
readonly secondaryClassId: BenchmarkRunReport["build"]["secondaryClassId"];
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly gearPresetId: string;
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly completedRunCount: number;
|
||||
readonly metrics: Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: BenchmarkMatrixReport["cohort"];
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly rows: readonly BenchmarkMatrixSummaryRow[];
|
||||
}
|
||||
|
||||
export const ROLE_BENCHMARK_METRICS = Object.freeze({
|
||||
damage: Object.freeze([
|
||||
"dps",
|
||||
"priorityTargetDamage",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
]),
|
||||
tank: Object.freeze([
|
||||
"dtps",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
"threatControlPercent",
|
||||
"minimumPlayerHealthPercent",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
"hps",
|
||||
"absorbsConsumed",
|
||||
"overhealPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"deaths",
|
||||
"timeToResourceFailureMs",
|
||||
]),
|
||||
} as const satisfies Readonly<Record<PartyRole, readonly BenchmarkMetricKey[]>>);
|
||||
|
||||
const MECHANIC_METRICS = Object.freeze([
|
||||
"movementInterrupts",
|
||||
"stackCompliancePercent",
|
||||
] as const satisfies readonly BenchmarkMetricKey[]);
|
||||
|
||||
const ROLE_ORDER: Readonly<Record<PartyRole, number>> = Object.freeze({
|
||||
tank: 0,
|
||||
healer: 1,
|
||||
damage: 2,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Readonly<Record<PartyRole, string>> = Object.freeze({
|
||||
tank: "Tank",
|
||||
healer: "Healer",
|
||||
damage: "Damage",
|
||||
});
|
||||
|
||||
const METRIC_LABELS: Readonly<Record<BenchmarkMetricKey, string>> = Object.freeze({
|
||||
rawDamage: "Raw damage",
|
||||
effectiveDamage: "Effective damage",
|
||||
priorityTargetDamage: "Priority damage",
|
||||
effectiveHealing: "Effective healing",
|
||||
rawHealing: "Raw healing",
|
||||
overheal: "Overheal",
|
||||
absorbsApplied: "Absorbs applied",
|
||||
absorbsConsumed: "Absorbs consumed",
|
||||
rawDamageTaken: "Raw damage taken",
|
||||
effectiveDamageTaken: "Effective damage taken",
|
||||
incomingAttacks: "Incoming attacks",
|
||||
avoidedAttacks: "Avoided attacks",
|
||||
deaths: "Deaths",
|
||||
minimumPlayerHealthPercent: "Minimum player health",
|
||||
minimumPartyHealthPercent: "Minimum party health",
|
||||
castsStarted: "Casts started",
|
||||
castsCompleted: "Casts completed",
|
||||
movementInterrupts: "Movement interrupts",
|
||||
invalidCasts: "Invalid casts",
|
||||
distanceMoved: "Distance moved",
|
||||
movementTimeMs: "Movement time",
|
||||
stackCompliancePercent: "Stack compliance",
|
||||
threatControlPercent: "Threat control",
|
||||
timeToResourceFailureMs: "Resource failure",
|
||||
timeToFirstDeathMs: "First death",
|
||||
dps: "DPS",
|
||||
hps: "HPS",
|
||||
dtps: "DTPS",
|
||||
overhealPercent: "Overheal",
|
||||
mitigationPercent: "Mitigation",
|
||||
avoidancePercent: "Avoidance",
|
||||
});
|
||||
|
||||
type BenchmarkReportInput = BenchmarkMatrixReport | BenchmarkMatrixSummary;
|
||||
|
||||
interface MutableAggregationGroup {
|
||||
readonly representative: BenchmarkRunReport;
|
||||
readonly runs: BenchmarkRunReport[];
|
||||
}
|
||||
|
||||
function finiteValues(values: readonly (number | null)[]): number[] {
|
||||
return values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a linearly interpolated quantile after dropping null and non-finite values.
|
||||
*/
|
||||
export function benchmarkQuantile(
|
||||
values: readonly (number | null)[],
|
||||
quantile: number,
|
||||
): number | null {
|
||||
if (quantile < 0 || quantile > 1 || !Number.isFinite(quantile)) {
|
||||
throw new RangeError(`Benchmark quantile must be between 0 and 1; received ${quantile}.`);
|
||||
}
|
||||
|
||||
const sorted = finiteValues(values).sort((left, right) => left - right);
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
|
||||
const rank = (sorted.length - 1) * quantile;
|
||||
const lowerIndex = Math.floor(rank);
|
||||
const upperIndex = Math.ceil(rank);
|
||||
const lower = sorted[lowerIndex];
|
||||
const upper = sorted[upperIndex];
|
||||
if (lowerIndex === upperIndex) return lower;
|
||||
return lower + ((upper - lower) * (rank - lowerIndex));
|
||||
}
|
||||
|
||||
function summarizeMetric(
|
||||
runs: readonly BenchmarkRunReport[],
|
||||
metric: BenchmarkMetricKey,
|
||||
): BenchmarkDistribution {
|
||||
const values = runs.map((run) => run.metrics[metric]);
|
||||
const observed = finiteValues(values);
|
||||
return {
|
||||
count: observed.length,
|
||||
nullCount: values.length - observed.length,
|
||||
median: benchmarkQuantile(observed, 0.5),
|
||||
p10: benchmarkQuantile(observed, 0.1),
|
||||
p90: benchmarkQuantile(observed, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregationKey(run: BenchmarkRunReport): string {
|
||||
return JSON.stringify([
|
||||
run.scenario.id,
|
||||
run.scenario.version,
|
||||
run.build.id,
|
||||
run.build.source,
|
||||
run.build.classId,
|
||||
run.build.secondaryClassId ?? null,
|
||||
run.build.role,
|
||||
run.build.level,
|
||||
run.build.orientation,
|
||||
run.build.gearPresetId,
|
||||
]);
|
||||
}
|
||||
|
||||
function compareSummaryRows(
|
||||
left: BenchmarkMatrixSummaryRow,
|
||||
right: BenchmarkMatrixSummaryRow,
|
||||
): number {
|
||||
return left.scenarioId.localeCompare(right.scenarioId)
|
||||
|| left.scenarioVersion - right.scenarioVersion
|
||||
|| ROLE_ORDER[left.role] - ROLE_ORDER[right.role]
|
||||
|| left.source.localeCompare(right.source)
|
||||
|| left.classId.localeCompare(right.classId)
|
||||
|| (left.secondaryClassId ?? "").localeCompare(right.secondaryClassId ?? "")
|
||||
|| left.buildId.localeCompare(right.buildId);
|
||||
}
|
||||
|
||||
export function aggregateBenchmarkMatrix(report: BenchmarkMatrixReport): BenchmarkMatrixSummary {
|
||||
const groups = new Map<string, MutableAggregationGroup>();
|
||||
|
||||
for (const run of report.runs) {
|
||||
const key = aggregationKey(run);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.runs.push(run);
|
||||
} else {
|
||||
groups.set(key, { representative: run, runs: [run] });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...groups.values()].map(({ representative, runs }) => {
|
||||
const validRuns = runs.filter((run) => run.validation.valid);
|
||||
const metrics = Object.fromEntries(BENCHMARK_METRIC_KEYS.map((metric) => (
|
||||
[metric, summarizeMetric(validRuns, metric)]
|
||||
))) as unknown as Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
|
||||
return {
|
||||
scenarioId: representative.scenario.id,
|
||||
scenarioVersion: representative.scenario.version,
|
||||
scenarioName: representative.scenario.name,
|
||||
scenarioFamily: representative.scenario.family,
|
||||
buildId: representative.build.id,
|
||||
source: representative.build.source,
|
||||
classId: representative.build.classId,
|
||||
secondaryClassId: representative.build.secondaryClassId ?? null,
|
||||
role: representative.build.role,
|
||||
level: representative.build.level,
|
||||
orientation: representative.build.orientation,
|
||||
gearPresetId: representative.build.gearPresetId,
|
||||
runCount: runs.length,
|
||||
validRunCount: validRuns.length,
|
||||
invalidRunCount: runs.length - validRuns.length,
|
||||
completedRunCount: runs.filter((run) => run.completed).length,
|
||||
metrics,
|
||||
} satisfies BenchmarkMatrixSummaryRow;
|
||||
}).sort(compareSummaryRows);
|
||||
|
||||
const validRunCount = report.runs.filter((run) => run.validation.valid).length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: report.generatedAt,
|
||||
appVersion: report.appVersion,
|
||||
cohort: report.cohort,
|
||||
runCount: report.runs.length,
|
||||
validRunCount,
|
||||
invalidRunCount: report.runs.length - validRunCount,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function asSummary(input: BenchmarkReportInput): BenchmarkMatrixSummary {
|
||||
return "rows" in input ? input : aggregateBenchmarkMatrix(input);
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | null): string {
|
||||
if (value === null) return "";
|
||||
const text = String(value);
|
||||
return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function csvLine(values: readonly (string | number | null)[]): string {
|
||||
return values.map(csvCell).join(",");
|
||||
}
|
||||
|
||||
/** Produces one CSV row per scenario/build/metric, retaining the complete metric vector. */
|
||||
export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [csvLine([
|
||||
"schema_version",
|
||||
"generated_at",
|
||||
"app_version",
|
||||
"cohort_level",
|
||||
"cohort_gear_preset_id",
|
||||
"seeds",
|
||||
"duration_scale",
|
||||
"policy_id",
|
||||
"scenario_id",
|
||||
"scenario_version",
|
||||
"scenario_name",
|
||||
"scenario_family",
|
||||
"build_id",
|
||||
"source",
|
||||
"class_id",
|
||||
"secondary_class_id",
|
||||
"role",
|
||||
"build_level",
|
||||
"orientation",
|
||||
"gear_preset_id",
|
||||
"run_count",
|
||||
"valid_run_count",
|
||||
"invalid_run_count",
|
||||
"completed_run_count",
|
||||
"metric",
|
||||
"observed_count",
|
||||
"null_count",
|
||||
"p10",
|
||||
"median",
|
||||
"p90",
|
||||
])];
|
||||
|
||||
for (const row of summary.rows) {
|
||||
for (const metric of BENCHMARK_METRIC_KEYS) {
|
||||
const distribution = row.metrics[metric];
|
||||
lines.push(csvLine([
|
||||
summary.schemaVersion,
|
||||
summary.generatedAt,
|
||||
summary.appVersion,
|
||||
summary.cohort.level,
|
||||
summary.cohort.gearPresetId,
|
||||
summary.cohort.seeds.join(";"),
|
||||
summary.cohort.durationScale,
|
||||
summary.cohort.policyId,
|
||||
row.scenarioId,
|
||||
row.scenarioVersion,
|
||||
row.scenarioName,
|
||||
row.scenarioFamily,
|
||||
row.buildId,
|
||||
row.source,
|
||||
row.classId,
|
||||
row.secondaryClassId ?? null,
|
||||
row.role,
|
||||
row.level,
|
||||
row.orientation,
|
||||
row.gearPresetId,
|
||||
row.runCount,
|
||||
row.validRunCount,
|
||||
row.invalidRunCount,
|
||||
row.completedRunCount,
|
||||
metric,
|
||||
distribution.count,
|
||||
distribution.nullCount,
|
||||
distribution.p10,
|
||||
distribution.median,
|
||||
distribution.p90,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function markdownCell(value: string): string {
|
||||
return value.replaceAll("|", "\\|").replace(/[\r\n]+/gu, " ");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) return value.toLocaleString("en-US");
|
||||
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: BenchmarkMetricKey, value: number): string {
|
||||
if (metric.endsWith("Percent")) return `${formatNumber(value)}%`;
|
||||
if (metric.endsWith("Ms")) return `${formatNumber(value / 1_000)}s`;
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function formatDistribution(metric: BenchmarkMetricKey, value: BenchmarkDistribution): string {
|
||||
if (value.median === null || value.p10 === null || value.p90 === null) {
|
||||
return value.nullCount > 0 ? `not observed (0/${value.nullCount})` : "no valid runs";
|
||||
}
|
||||
|
||||
const range = `${formatMetricValue(metric, value.p10)}–${formatMetricValue(metric, value.p90)}`;
|
||||
const observation = value.nullCount > 0
|
||||
? `; observed ${value.count}/${value.count + value.nullCount}`
|
||||
: "";
|
||||
return `${formatMetricValue(metric, value.median)} [${range}]${observation}`;
|
||||
}
|
||||
|
||||
function roleMetricsFor(row: BenchmarkMatrixSummaryRow): readonly BenchmarkMetricKey[] {
|
||||
if (row.scenarioFamily !== "mechanics") return ROLE_BENCHMARK_METRICS[row.role];
|
||||
return [...ROLE_BENCHMARK_METRICS[row.role], ...MECHANIC_METRICS];
|
||||
}
|
||||
|
||||
function formatRoleVector(row: BenchmarkMatrixSummaryRow): string {
|
||||
return roleMetricsFor(row).map((metric) => (
|
||||
`${METRIC_LABELS[metric]}: ${formatDistribution(metric, row.metrics[metric])}`
|
||||
)).join("; ");
|
||||
}
|
||||
|
||||
/** Renders a compact, role-separated view without collapsing metrics into a score. */
|
||||
export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [
|
||||
"# Balance benchmark summary",
|
||||
"",
|
||||
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
||||
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
||||
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
||||
`Policy: \`${summary.cohort.policyId}\`.`,
|
||||
"",
|
||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||
+ "Values are median [p10–p90].",
|
||||
];
|
||||
|
||||
for (const role of ["tank", "healer", "damage"] as const) {
|
||||
const roleRows = summary.rows.filter((row) => row.role === role);
|
||||
if (roleRows.length === 0) continue;
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`## ${ROLE_LABELS[role]}`,
|
||||
"",
|
||||
"| Scenario | Build | Runs | Role vector |",
|
||||
"| --- | --- | ---: | --- |",
|
||||
);
|
||||
for (const row of roleRows) {
|
||||
const scenario = `${row.scenarioName} (v${row.scenarioVersion})`;
|
||||
const secondary = row.secondaryClassId ? ` / ${row.secondaryClassId}` : "";
|
||||
const build = `${row.buildId} — ${row.source}:${row.classId}${secondary}`;
|
||||
const runs = `${row.validRunCount}/${row.runCount}`;
|
||||
lines.push(
|
||||
`| ${markdownCell(scenario)} | ${markdownCell(build)} | ${runs} | `
|
||||
+ `${markdownCell(formatRoleVector(row))} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.rows.length === 0) {
|
||||
lines.push("", "No benchmark runs were supplied.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Compare builds only within the same scenario version and cohort. "
|
||||
+ "Role metrics remain separate; this report does not calculate a composite score.",
|
||||
);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import { runCombatBenchmark } from "./benchmarkRunner";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
describe("combat benchmark runner", () => {
|
||||
it("replays the authoritative player path deterministically", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["mage"]);
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
const options = { seed: 42, durationScale: 0.12 } as const;
|
||||
|
||||
const first = await runCombatBenchmark(build, scenario, options);
|
||||
const second = await runCombatBenchmark(build, scenario, options);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(first.metrics.effectiveDamage).toBeGreaterThan(0);
|
||||
expect(first.metrics.priorityTargetDamage).toBe(first.metrics.effectiveDamage);
|
||||
expect(first.metrics.castsCompleted).toBeGreaterThan(0);
|
||||
expect(first.eventCounts.damage).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("treats avoided reference attacks as valid tank pressure", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["guardian"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidedAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidancePercent).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps benchmark bosses attacking through player hard control", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["starcaller"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
|
||||
import type { AzerothCoreMechanic } from "../azerothCoreReference";
|
||||
import { installCombatClock } from "../combatClock";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import type { EnemyAttackDefinition } from "../enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
getPartyRuntimePosition,
|
||||
registerPartyRuntimePosition,
|
||||
} from "../partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { CombatPosition } from "../mobRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BenchmarkRecorder } from "./benchmarkRecorder";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkRunOptions,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
|
||||
"charm",
|
||||
"disarm",
|
||||
"disorient",
|
||||
"fear",
|
||||
"freeze",
|
||||
"grip",
|
||||
"horror",
|
||||
"knockout",
|
||||
"pacify",
|
||||
"polymorph",
|
||||
"root",
|
||||
"sap",
|
||||
"shackle",
|
||||
"silence",
|
||||
"sleep",
|
||||
"snare",
|
||||
"stun",
|
||||
"turn",
|
||||
]);
|
||||
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
|
||||
"blind",
|
||||
"charm",
|
||||
"confuse",
|
||||
"disarm",
|
||||
"fear",
|
||||
"freeze",
|
||||
"knockdown",
|
||||
"pacify",
|
||||
"silence",
|
||||
"sleep",
|
||||
"stun",
|
||||
]);
|
||||
|
||||
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
|
||||
const requested = scenario.durationMs * Math.max(0.001, scale);
|
||||
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
|
||||
}
|
||||
|
||||
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
|
||||
if (layout === "spread") {
|
||||
const spread: readonly CombatPosition[] = [
|
||||
[0, 0, 1],
|
||||
[-12, 0, -3],
|
||||
[12, 0, -3],
|
||||
[0, 0, -12],
|
||||
];
|
||||
return spread.slice(0, count);
|
||||
}
|
||||
if (layout === "stacked") {
|
||||
const stacked: readonly CombatPosition[] = [
|
||||
[-1, 0, 0.5],
|
||||
[1, 0, 0.5],
|
||||
[-0.5, 0, -1],
|
||||
[0.5, 0, -1],
|
||||
];
|
||||
return stacked.slice(0, count);
|
||||
}
|
||||
const melee: readonly CombatPosition[] = [
|
||||
[-1.5, 0, 1],
|
||||
[1.5, 0, 1],
|
||||
[-2.5, 0, -1],
|
||||
[2.5, 0, -1],
|
||||
];
|
||||
return melee.slice(0, count);
|
||||
}
|
||||
|
||||
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
|
||||
if (index === 0 || count <= 1) return [0, 0, 2.5];
|
||||
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
|
||||
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
|
||||
}
|
||||
|
||||
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
|
||||
return scenario.id === "damage-aoe-eight" ? 6 : 2;
|
||||
}
|
||||
|
||||
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
|
||||
function restoreBenchmarkMobPositions(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
mobIds: readonly string[],
|
||||
): void {
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => setMobPosition(
|
||||
id,
|
||||
mobPosition(index, mobIds.length, ringRadius),
|
||||
));
|
||||
}
|
||||
|
||||
function referenceAttackDamage(level: number): number {
|
||||
return 12 + level * 2;
|
||||
}
|
||||
|
||||
function enemyAttacks(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
level: number,
|
||||
referenceHealth: number,
|
||||
): readonly EnemyAttackDefinition[] {
|
||||
const baseDamage = referenceAttackDamage(level);
|
||||
return scenario.attacks.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
|
||||
target: profile.target,
|
||||
school: profile.school,
|
||||
animation: profile.school === "physical" ? "attack" : "cast",
|
||||
range: profile.target === "primary" ? 5.5 : 45,
|
||||
cooldownMs: profile.intervalMs,
|
||||
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
|
||||
...(profile.radius === undefined ? {} : { radius: profile.radius }),
|
||||
}));
|
||||
}
|
||||
|
||||
function movementPosition(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
elapsedMs: number,
|
||||
): { readonly position: CombatPosition; readonly moving: boolean } {
|
||||
const movement = scenario.movement;
|
||||
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
|
||||
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
|
||||
if (cycle < movement.outboundMs) {
|
||||
return {
|
||||
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
|
||||
moving: true,
|
||||
};
|
||||
}
|
||||
const returnStart = movement.outboundMs;
|
||||
if (cycle < returnStart + movement.returnMs) {
|
||||
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
|
||||
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
|
||||
}
|
||||
return { position: [0, 0, 0], moving: false };
|
||||
}
|
||||
|
||||
function distance(left: CombatPosition, right: CombatPosition): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
|
||||
if (scenario.partyLayout !== "stacked") return null;
|
||||
const combat = useCombatStore.getState();
|
||||
return usePartyStore.getState().members
|
||||
.filter((member) => member.health > 0)
|
||||
.every((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position !== null && distance(combat.playerPosition, position) <= 5;
|
||||
});
|
||||
}
|
||||
|
||||
function primaryThreatSource(role: PartyRole): ThreatSource {
|
||||
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
return {
|
||||
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
|
||||
role: "tank",
|
||||
abilityId: "benchmark-pull",
|
||||
};
|
||||
}
|
||||
|
||||
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
|
||||
const source = primaryThreatSource(role);
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
|
||||
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
|
||||
for (const member of damageMembers) {
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(
|
||||
id,
|
||||
Math.max(1, Math.round(member.level * 0.9)),
|
||||
now,
|
||||
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
|
||||
"physical",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceExecuteWindow(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
priorityTargetId: string,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[priorityTargetId];
|
||||
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
|
||||
return {
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
|
||||
function clearBenchmarkControlStatuses(): void {
|
||||
useCombatStore.setState((state) => {
|
||||
let changed = false;
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
|
||||
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
|
||||
if (statuses.length !== mob.statuses.length) changed = true;
|
||||
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
|
||||
}));
|
||||
return changed ? { mobs } : state;
|
||||
});
|
||||
}
|
||||
|
||||
function anyInjuredBelow(percent: number): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
|
||||
return usePartyStore.getState().members.some((member) => (
|
||||
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
|
||||
));
|
||||
}
|
||||
|
||||
/** Runs one build/scenario pair against the real authoritative player combat path. */
|
||||
export async function runCombatBenchmark(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
options: BenchmarkRunOptions = {},
|
||||
): Promise<BenchmarkRunReport> {
|
||||
if (!scenario.roles.includes(build.role)) {
|
||||
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
|
||||
}
|
||||
const seed = Math.trunc(options.seed ?? 1);
|
||||
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
|
||||
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
|
||||
await preloadAbilityCatalogsForClasses(classIds);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = BENCHMARK_CLOCK_ORIGIN;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let recorder: BenchmarkRecorder | null = null;
|
||||
let simulatedDurationMs = 0;
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
equipment: build.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([0, 0, 0]);
|
||||
const initializedCombat = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty(
|
||||
`benchmark:${build.id}:${scenario.id}:${seed}`,
|
||||
build.level,
|
||||
initializedCombat.maxHealth,
|
||||
build.role,
|
||||
5,
|
||||
build.source,
|
||||
);
|
||||
const party = usePartyStore.getState();
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
|
||||
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
|
||||
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => {
|
||||
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
|
||||
boss: index === 0,
|
||||
level: build.level,
|
||||
maxHealth: BENCHMARK_MOB_HEALTH,
|
||||
xpReward: 0,
|
||||
hasLoot: false,
|
||||
moveSpeed: 0,
|
||||
aggroRange: 45,
|
||||
leashRange: 60,
|
||||
attacks,
|
||||
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
|
||||
});
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
seedThreat(mobIds, build.role, now);
|
||||
useCombatStore.getState().selectMob(mobIds[0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
|
||||
const controller = new BenchmarkController();
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs: 0,
|
||||
combat: useCombatStore.getState(),
|
||||
party: usePartyStore.getState(),
|
||||
movedDistance: 0,
|
||||
moving: false,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: 0,
|
||||
totalThreatMobs: 0,
|
||||
});
|
||||
|
||||
let resourceFailureDecisions = 0;
|
||||
let previousPosition: CombatPosition = [0, 0, 0];
|
||||
let nextThreatCompetitionAt = 2_000;
|
||||
while (simulatedDurationMs < durationMs) {
|
||||
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
|
||||
simulatedDurationMs += deltaMs;
|
||||
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
|
||||
const movement = movementPosition(scenario, simulatedDurationMs);
|
||||
useCombatStore.getState().setPlayerPosition(movement.position);
|
||||
const movedDistance = distance(previousPosition, movement.position);
|
||||
previousPosition = movement.position;
|
||||
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
useCombatStore.getState().tick(deltaMs / 1_000, now);
|
||||
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
if (scenario.attacks.length) {
|
||||
clearBenchmarkControlStatuses();
|
||||
useCombatStore.getState().advanceMobCombat(now, movement.position);
|
||||
}
|
||||
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
|
||||
applyTankThreatCompetition(mobIds, now);
|
||||
nextThreatCompetitionAt += 2_000;
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const decision = controller.act(now, build.role, scenario, movement.moving);
|
||||
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
|
||||
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
|
||||
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
|
||||
? 0
|
||||
: resourceFailureDecisions + 1;
|
||||
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
|
||||
}
|
||||
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const currentParty = usePartyStore.getState();
|
||||
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs,
|
||||
combat: currentCombat,
|
||||
party: currentParty,
|
||||
movedDistance,
|
||||
moving: movement.moving,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: build.role === "tank"
|
||||
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
|
||||
: 0,
|
||||
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
|
||||
});
|
||||
|
||||
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
|
||||
const partyDied = currentParty.members.some((member) => member.health <= 0)
|
||||
&& (scenario.stopOnPartyDeath || build.role === "healer");
|
||||
const resourceFailed = scenario.resourceFailureGcds !== undefined
|
||||
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
|
||||
if (playerDied || partyDied || resourceFailed) {
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.finish(simulatedDurationMs, completed);
|
||||
} catch (error: unknown) {
|
||||
if (!recorder) throw error;
|
||||
const partial = recorder.finish(simulatedDurationMs, false);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Object.freeze({
|
||||
...partial,
|
||||
completed: false,
|
||||
validation: Object.freeze({
|
||||
valid: false,
|
||||
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
recorder?.dispose();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects only scenario/build combinations that share a declared role. */
|
||||
export function compatibleBenchmarkPairs(
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
scenarios: readonly BenchmarkScenarioDefinition[],
|
||||
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
|
||||
return builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => [build, scenario] as const));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import { BENCHMARK_SCENARIOS, benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
const EXPECTED_DURATIONS_MS: Readonly<Record<string, number>> = Object.freeze({
|
||||
"damage-single-burst": 30_000,
|
||||
"damage-single-sustained": 180_000,
|
||||
"damage-cleave-three": 90_000,
|
||||
"damage-aoe-eight": 60_000,
|
||||
"tank-mixed-boss": 180_000,
|
||||
"tank-multi-target": 120_000,
|
||||
"heal-single-target": 90_000,
|
||||
"heal-spread-party": 180_000,
|
||||
"heal-stacked-party": 90_000,
|
||||
"heal-resource-endurance": 600_000,
|
||||
"mechanic-movement-boss": 180_000,
|
||||
"mechanic-stack-boss": 180_000,
|
||||
});
|
||||
|
||||
describe("benchmark scenario catalog", () => {
|
||||
it("uses unique stable ids and positive integer versions", () => {
|
||||
const ids = BENCHMARK_SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
expect(ids).toHaveLength(Object.keys(EXPECTED_DURATIONS_MS).length);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect([...ids].sort()).toEqual(Object.keys(EXPECTED_DURATIONS_MS).sort());
|
||||
expect(BENCHMARK_SCENARIOS.every(
|
||||
(scenario) => Number.isInteger(scenario.version) && scenario.version >= 1,
|
||||
)).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => scenario.version === 1)).toBe(true);
|
||||
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(benchmarkScenarioById(scenario.id)).toBe(scenario);
|
||||
}
|
||||
expect(benchmarkScenarioById("missing-scenario")).toBeNull();
|
||||
});
|
||||
|
||||
it("covers every combat role and keeps role-specific families scoped", () => {
|
||||
const expectedRoles: readonly PartyRole[] = ["tank", "healer", "damage"];
|
||||
const coveredRoles = new Set(BENCHMARK_SCENARIOS.flatMap((scenario) => scenario.roles));
|
||||
|
||||
expect([...coveredRoles].sort()).toEqual([...expectedRoles].sort());
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => (
|
||||
scenario.roles.length > 0
|
||||
&& scenario.roles.every((role) => expectedRoles.includes(role))
|
||||
))).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "damage")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "damage"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "tanking")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "tank"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => (
|
||||
scenario.family === "healing" || scenario.family === "endurance"
|
||||
)).every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "healer"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "mechanics")
|
||||
.every((scenario) => expectedRoles.every((role) => scenario.roles.includes(role))))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("preserves exact durations on an integral fixed-step timeline", () => {
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(scenario.durationMs).toBe(EXPECTED_DURATIONS_MS[scenario.id]);
|
||||
expect(Number.isInteger(scenario.durationMs)).toBe(true);
|
||||
expect(Number.isInteger(scenario.tickMs)).toBe(true);
|
||||
expect(scenario.durationMs).toBeGreaterThan(0);
|
||||
expect(scenario.tickMs).toBeGreaterThan(0);
|
||||
expect(scenario.durationMs % scenario.tickMs).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { BenchmarkEnemyAttackProfile, BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
const roles = ["tank", "healer", "damage"] as const;
|
||||
|
||||
const attack = (
|
||||
id: string,
|
||||
name: string,
|
||||
target: BenchmarkEnemyAttackProfile["target"],
|
||||
intervalMs: number,
|
||||
damageMultiplier: number,
|
||||
options: Pick<BenchmarkEnemyAttackProfile, "school" | "radius"> = { school: "physical" },
|
||||
): BenchmarkEnemyAttackProfile => Object.freeze({
|
||||
id,
|
||||
name,
|
||||
target,
|
||||
intervalMs,
|
||||
damageMultiplier,
|
||||
school: options.school,
|
||||
...(options.radius === undefined ? {} : { radius: options.radius }),
|
||||
});
|
||||
|
||||
/** Versioned, fixed-input contracts used by the automated balance matrix. */
|
||||
export const BENCHMARK_SCENARIOS: readonly BenchmarkScenarioDefinition[] = Object.freeze([
|
||||
{
|
||||
id: "damage-single-burst",
|
||||
version: 1,
|
||||
name: "Single-target burst",
|
||||
description: "Thirty seconds on an immortal boss with all cooldowns ready.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 30_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-single-sustained",
|
||||
version: 1,
|
||||
name: "Single-target sustained",
|
||||
description: "Three minutes on an immortal boss, including a scripted execute window.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-cleave-three",
|
||||
version: 1,
|
||||
name: "Three-target cleave",
|
||||
description: "Three immortal targets packed within five yards of the priority target.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 3,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-aoe-eight",
|
||||
version: 1,
|
||||
name: "Eight-target AoE",
|
||||
description: "A priority target surrounded by seven immortal targets on a six-yard ring.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 60_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 8,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "tank-mixed-boss",
|
||||
version: 1,
|
||||
name: "Mixed boss tanking",
|
||||
description: "Physical swings plus a periodic magical tank buster.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-swing", "Reference Swing", "primary", 2_000, 0.08),
|
||||
attack("benchmark-buster", "Arcane Buster", "primary", 20_000, 0.42, { school: "arcane" }),
|
||||
],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "tank-multi-target",
|
||||
version: 1,
|
||||
name: "Multi-target tanking",
|
||||
description: "Five enemies test AoE threat, mitigation, and survival under many swings.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 120_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 5,
|
||||
partyLayout: "melee",
|
||||
attacks: [attack("benchmark-add-swing", "Add Swing", "primary", 2_000, 0.022)],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-single-target",
|
||||
version: 1,
|
||||
name: "Single-target healing",
|
||||
description: "A reference tank takes sustained swings and fifteen-second burst hits.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-tank-pressure", "Tank Pressure", "primary", 2_000, 0.12),
|
||||
attack("benchmark-tank-spike", "Tank Spike", "primary", 15_000, 0.55, { school: "shadow" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-spread-party",
|
||||
version: 1,
|
||||
name: "Spread party healing",
|
||||
description: "Party members spread apart while raid pulses and deterministic spot damage land.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-spread-tank", "Tank Pressure", "primary", 2_000, 0.07),
|
||||
attack("benchmark-spread-pulse", "Spread Pulse", "nearby-party", 6_000, 0.12, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-spread-spot", "Spot Damage", "random-party", 12_000, 0.32, { school: "fire" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-stacked-party",
|
||||
version: 1,
|
||||
name: "Stacked party healing",
|
||||
description: "The group stacks within five yards for rapid party-wide pulses.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-tank", "Tank Pressure", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-pulse", "Stack Pulse", "nearby-party", 3_000, 0.18, { school: "arcane", radius: 8 }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-resource-endurance",
|
||||
version: 1,
|
||||
name: "Healing resource endurance",
|
||||
description: "Up to ten minutes of sustained tank and party pressure.",
|
||||
family: "endurance",
|
||||
roles: ["healer"],
|
||||
durationMs: 600_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-endurance-tank", "Endurance Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-endurance-party", "Endurance Pulse", "nearby-party", 4_000, 0.045, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-endurance-spot", "Endurance Spot Hit", "random-party", 15_000, 0.25, { school: "frost" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
resourceFailureGcds: 3,
|
||||
},
|
||||
{
|
||||
id: "mechanic-movement-boss",
|
||||
version: 1,
|
||||
name: "Movement boss",
|
||||
description: "Repeated outbound and return movement interrupts stationary casts and reduces uptime.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-movement-swing", "Movement Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-movement-pulse", "Movement Pulse", "nearby-party", 8_000, 0.06, { school: "fire", radius: 40 }),
|
||||
],
|
||||
movement: { startsAtMs: 15_000, periodMs: 20_000, outboundMs: 4_000, returnMs: 2_000, distance: 8 },
|
||||
},
|
||||
{
|
||||
id: "mechanic-stack-boss",
|
||||
version: 1,
|
||||
name: "Stack boss",
|
||||
description: "The party remains tightly grouped so proximity healing and split-pressure tools are exercised.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-swing", "Stack Boss Swing", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-mechanic", "Shared Burden", "nearby-party", 6_000, 0.15, { school: "shadow", radius: 8 }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function benchmarkScenarioById(id: string): BenchmarkScenarioDefinition | null {
|
||||
return BENCHMARK_SCENARIOS.find((scenario) => scenario.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
|
||||
export type BenchmarkScenarioFamily = "damage" | "tanking" | "healing" | "mechanics" | "endurance";
|
||||
export type BenchmarkPartyLayout = "none" | "melee" | "spread" | "stacked";
|
||||
|
||||
export interface BenchmarkMovementPattern {
|
||||
readonly startsAtMs: number;
|
||||
readonly periodMs: number;
|
||||
readonly outboundMs: number;
|
||||
readonly returnMs: number;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkEnemyAttackProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly target: "primary" | "random-party" | "nearby-party";
|
||||
readonly school: "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy";
|
||||
readonly intervalMs: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkScenarioDefinition {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly family: BenchmarkScenarioFamily;
|
||||
readonly roles: readonly PartyRole[];
|
||||
readonly durationMs: number;
|
||||
readonly tickMs: number;
|
||||
readonly hostileTargets: number;
|
||||
readonly partyLayout: BenchmarkPartyLayout;
|
||||
readonly attacks: readonly BenchmarkEnemyAttackProfile[];
|
||||
readonly movement?: BenchmarkMovementPattern;
|
||||
readonly stopOnPlayerDeath?: boolean;
|
||||
readonly stopOnPartyDeath?: boolean;
|
||||
readonly resourceFailureGcds?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
readonly gearPresetId: string;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunOptions {
|
||||
readonly seed?: number;
|
||||
readonly durationScale?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkAbilityMetrics {
|
||||
readonly abilityId: string;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly interrupts: number;
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResourceMetrics {
|
||||
readonly id: string;
|
||||
readonly starting: number;
|
||||
readonly ending: number;
|
||||
readonly minimum: number;
|
||||
readonly maximum: number;
|
||||
readonly timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunMetrics {
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly priorityTargetDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly rawHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
readonly absorbsConsumed: number;
|
||||
readonly rawDamageTaken: number;
|
||||
readonly effectiveDamageTaken: number;
|
||||
readonly incomingAttacks: number;
|
||||
readonly avoidedAttacks: number;
|
||||
readonly deaths: number;
|
||||
readonly minimumPlayerHealthPercent: number;
|
||||
readonly minimumPartyHealthPercent: number;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly movementInterrupts: number;
|
||||
readonly invalidCasts: number;
|
||||
readonly distanceMoved: number;
|
||||
readonly movementTimeMs: number;
|
||||
readonly stackCompliancePercent: number | null;
|
||||
readonly threatControlPercent: number | null;
|
||||
readonly timeToResourceFailureMs: number | null;
|
||||
readonly timeToFirstDeathMs: number | null;
|
||||
readonly dps: number;
|
||||
readonly hps: number;
|
||||
readonly dtps: number;
|
||||
readonly overhealPercent: number;
|
||||
readonly mitigationPercent: number;
|
||||
readonly avoidancePercent: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkValidation {
|
||||
readonly valid: boolean;
|
||||
readonly reasons: readonly string[];
|
||||
}
|
||||
|
||||
export interface BenchmarkRunReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly policyId: string;
|
||||
readonly seed: number;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
readonly scenario: BenchmarkScenarioDefinition;
|
||||
readonly simulatedDurationMs: number;
|
||||
readonly completed: boolean;
|
||||
readonly metrics: BenchmarkRunMetrics;
|
||||
readonly resources: readonly BenchmarkResourceMetrics[];
|
||||
readonly abilities: readonly BenchmarkAbilityMetrics[];
|
||||
readonly eventCounts: Readonly<Record<string, number>>;
|
||||
readonly validation: BenchmarkValidation;
|
||||
readonly gearStats: ItemStats;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: {
|
||||
readonly level: number;
|
||||
readonly gearPresetId: string;
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly policyId: string;
|
||||
};
|
||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||
readonly runs: readonly BenchmarkRunReport[];
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const clips = [
|
||||
{ name: "Fall (ID 40 variation 0)" },
|
||||
{ name: "JumpEnd (ID 39 variation 0)" },
|
||||
{ name: "JumpLandRun (ID 133 variation 0)" },
|
||||
{ name: "Ready1H (ID 26 variation 0)" },
|
||||
{ name: "Ready2H (ID 27 variation 0)" },
|
||||
{ name: "Ready2HL (ID 28 variation 0)" },
|
||||
{ name: "ReadyBow (ID 29 variation 0)" },
|
||||
{ name: "HoldBow (ID 109 variation 0)" },
|
||||
];
|
||||
|
||||
describe("WoW character animation selection", () => {
|
||||
@@ -43,6 +48,31 @@ describe("WoW character animation selection", () => {
|
||||
expect(selectCharacterBaseClip(clips, "run")).toBe(clips[2]);
|
||||
});
|
||||
|
||||
it("uses the native ready pose for the equipped weapon stance", () => {
|
||||
expect(selectCharacterBaseClip(clips, "idle", "one-handed")).toBe(clips[15]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed")).toBe(clips[16]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed-left")).toBe(clips[17]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "bow")).toBe(clips[19]);
|
||||
expect(selectCharacterBaseClip(clips, "run", "bow")).toBe(clips[2]);
|
||||
expect(selectCharacterBaseClip([
|
||||
clips[0],
|
||||
clips[15],
|
||||
], "idle", "bow")).toBe(clips[15]);
|
||||
|
||||
const runewakerClips = [
|
||||
{ name: "Stand - stand_idle01" },
|
||||
{ name: "Idle - 1h_idle" },
|
||||
{ name: "Idle - 2h_idle" },
|
||||
{ name: "Idle - bow_idle" },
|
||||
];
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "one-handed"))
|
||||
.toBe(runewakerClips[1]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "two-handed-left"))
|
||||
.toBe(runewakerClips[2]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "bow"))
|
||||
.toBe(runewakerClips[3]);
|
||||
});
|
||||
|
||||
it("rotates every variation of the highest-priority available ID", () => {
|
||||
expect(selectWoWAnimationVariation(clips, [16], 0)).toBe(clips[9]);
|
||||
expect(selectWoWAnimationVariation(clips, [16], 1)).toBe(clips[10]);
|
||||
|
||||
@@ -32,6 +32,12 @@ type RunewakerCombatMotion = "melee" | "bow" | "spell";
|
||||
|
||||
export type CharacterBaseMotion = "idle" | "walk" | "run";
|
||||
|
||||
export type CharacterWeaponStance =
|
||||
| "bow"
|
||||
| "one-handed"
|
||||
| "two-handed"
|
||||
| "two-handed-left";
|
||||
|
||||
export interface CharacterClipSelection<TClip extends NamedAnimationClip> {
|
||||
readonly clip: TClip;
|
||||
readonly loop: boolean;
|
||||
@@ -108,7 +114,21 @@ export function selectWoWAnimationVariation<TClip extends NamedAnimationClip>(
|
||||
export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
|
||||
clips: readonly TClip[],
|
||||
motion: CharacterBaseMotion,
|
||||
weaponStance?: CharacterWeaponStance,
|
||||
): TClip | undefined {
|
||||
if (motion === "idle" && weaponStance) {
|
||||
const readyIds = weaponStance === "bow"
|
||||
? [109, 29, 26]
|
||||
: weaponStance === "one-handed"
|
||||
? [26]
|
||||
: weaponStance === "two-handed-left" ? [28, 27] : [27, 28];
|
||||
const runewakerIdle = weaponStance === "bow"
|
||||
? "idle - bow_idle"
|
||||
: weaponStance === "one-handed" ? "idle - 1h_idle" : "idle - 2h_idle";
|
||||
const ready = selectWoWAnimationVariation(clips, readyIds)
|
||||
?? clips.find((clip) => clip.name.toLowerCase().startsWith(runewakerIdle));
|
||||
if (ready) return ready;
|
||||
}
|
||||
const ids = motion === "run" ? [5, 4, 0] : motion === "walk" ? [4, 5, 0] : [0, 4, 5];
|
||||
return selectWoWAnimationVariation(clips, ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
type CombatPresentationEvent,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
playMasterAudioCue,
|
||||
} from "./combatAudio";
|
||||
|
||||
const fixtureEvent: CombatPresentationEvent = {
|
||||
id: 1,
|
||||
sessionId: 1,
|
||||
occurredAt: 1,
|
||||
phase: "impact",
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
targetActorId: "enemy",
|
||||
abilityId: "fixture",
|
||||
name: "Fixture",
|
||||
source: "fallback",
|
||||
school: "holy",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
await expect(playMasterAudioCue("/fixture.wav")).resolves.toBeNull();
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicitly enabled positive-volume output", () => {
|
||||
expect(masterAudioOutputEnabled(0)).toBe(false);
|
||||
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not initialize audio for a zero-volume actor group", () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
configureCombatAudio({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
audioVolume: {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS.audioVolume,
|
||||
player: 0,
|
||||
},
|
||||
}, 0.6);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+56
-5
@@ -18,11 +18,35 @@ let context: AudioContext | null = null;
|
||||
let masterGain: GainNode | null = null;
|
||||
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
|
||||
let configuredSettings: CombatPresentationSettings | null = null;
|
||||
let configuredMasterVolume = 0.8;
|
||||
let configuredMasterVolume = 0;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
export function masterAudioOutputEnabled(masterVolume: number): boolean {
|
||||
return Number.isFinite(masterVolume) && masterVolume > 0;
|
||||
}
|
||||
|
||||
export function combatAudioOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& (["player", "party", "enemy"] as const).some((group) => (
|
||||
settings.audioEnabled[group] && settings.audioVolume[group] > 0
|
||||
));
|
||||
}
|
||||
|
||||
function combatAudioGroupOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
group: CombatActorGroup,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& settings.audioEnabled[group]
|
||||
&& settings.audioVolume[group] > 0;
|
||||
}
|
||||
|
||||
function audioContextConstructor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.AudioContext
|
||||
@@ -66,7 +90,11 @@ export function configureCombatAudio(
|
||||
configuredSettings = settings;
|
||||
configuredMasterVolume = Number.isFinite(masterVolume)
|
||||
? Math.max(0, Math.min(1, masterVolume))
|
||||
: 0.8;
|
||||
: 0;
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) {
|
||||
resetCombatAudio();
|
||||
if (context?.state === "running") void context.suspend().catch(() => undefined);
|
||||
}
|
||||
applyCombatAudioSettings();
|
||||
}
|
||||
|
||||
@@ -113,6 +141,10 @@ const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
@@ -181,9 +213,15 @@ export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (!audioContext || !buffer || !masterGain) return null;
|
||||
if (
|
||||
!masterAudioOutputEnabled(configuredMasterVolume)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !masterGain
|
||||
) return null;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -211,9 +249,19 @@ export async function playMasterAudioCue(
|
||||
|
||||
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return false;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(sound.url);
|
||||
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !groupGains
|
||||
) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -248,7 +296,10 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
return;
|
||||
}
|
||||
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface AuraProcState {
|
||||
export interface AuraDefinition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** Player-facing explanation shown by aura tooltips. */
|
||||
readonly description?: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
/** Spell artwork used by every generic aura HUD surface. */
|
||||
readonly icon?: string;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { combatNow, installCombatClock } from "./combatClock";
|
||||
|
||||
describe("combat clock", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("installs a deterministic clock and restores wall time", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(42_000);
|
||||
const restore = installCombatClock(() => 1_234);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(1_234);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(42_000);
|
||||
});
|
||||
|
||||
it("restores nested clock installations in last-in-first-out order", () => {
|
||||
const restoreOuter = installCombatClock(() => 100);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(100);
|
||||
const restoreInner = installCombatClock(() => 200);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(200);
|
||||
} finally {
|
||||
restoreInner();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(100);
|
||||
} finally {
|
||||
restoreOuter();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type CombatClock = () => number;
|
||||
|
||||
let activeCombatClock: CombatClock = () => Date.now();
|
||||
|
||||
/** Shared simulation time. Production uses wall time; deterministic tools inject a fixed-step clock. */
|
||||
export function combatNow(): number {
|
||||
return activeCombatClock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the combat clock and returns a disposer that restores the previous
|
||||
* source. Benchmark callers must run sequentially because combat stores are
|
||||
* process-wide singletons.
|
||||
*/
|
||||
export function installCombatClock(clock: CombatClock): () => void {
|
||||
const previous = activeCombatClock;
|
||||
activeCombatClock = clock;
|
||||
return () => {
|
||||
activeCombatClock = previous;
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,26 @@ export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||
|
||||
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||
|
||||
export type CombatEventListener = (event: StructuredCombatEvent) => void;
|
||||
|
||||
const combatEventListeners = new Set<CombatEventListener>();
|
||||
|
||||
/** Lossless event stream for recorders; the in-store history remains a small HUD ring. */
|
||||
export function subscribeCombatEvents(listener: CombatEventListener): () => void {
|
||||
combatEventListeners.add(listener);
|
||||
return () => combatEventListeners.delete(listener);
|
||||
}
|
||||
|
||||
function publishCombatEvent(event: StructuredCombatEvent): void {
|
||||
for (const listener of combatEventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// A metrics consumer must never interrupt authoritative combat.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appendCombatEvent(
|
||||
events: readonly StructuredCombatEvent[],
|
||||
event: CombatEventInput,
|
||||
@@ -45,5 +65,6 @@ export function appendCombatEvent(
|
||||
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||
});
|
||||
publishCombatEvent(next);
|
||||
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_COMBAT_EVENT_HISTORY,
|
||||
appendCombatEvent,
|
||||
subscribeCombatEvents,
|
||||
type CombatEventInput,
|
||||
type StructuredCombatEvent,
|
||||
} from "./combatEvents";
|
||||
|
||||
function eventInput(index: number): CombatEventInput {
|
||||
return {
|
||||
occurredAt: index * 100,
|
||||
kind: "damage",
|
||||
sourceActorId: "benchmark-source",
|
||||
targetActorId: "benchmark-target",
|
||||
abilityId: "benchmark-strike",
|
||||
school: "physical",
|
||||
rawAmount: index,
|
||||
effectiveAmount: index,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combat event subscriptions", () => {
|
||||
it("publishes every event while retaining only the bounded HUD history", () => {
|
||||
const published: StructuredCombatEvent[] = [];
|
||||
const unsubscribe = subscribeCombatEvents((event) => published.push(event));
|
||||
let history: readonly StructuredCombatEvent[] = [];
|
||||
const eventCount = MAX_COMBAT_EVENT_HISTORY + 75;
|
||||
|
||||
try {
|
||||
for (let id = 1; id <= eventCount; id += 1) {
|
||||
history = appendCombatEvent(history, eventInput(id), id);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(published).toHaveLength(eventCount);
|
||||
expect(published.map((event) => event.id)).toEqual(
|
||||
Array.from({ length: eventCount }, (_, index) => index + 1),
|
||||
);
|
||||
expect(history).toHaveLength(MAX_COMBAT_EVENT_HISTORY);
|
||||
expect(history[0]?.id).toBe(eventCount - MAX_COMBAT_EVENT_HISTORY + 1);
|
||||
expect(history.at(-1)?.id).toBe(eventCount);
|
||||
|
||||
appendCombatEvent(history, eventInput(eventCount + 1), eventCount + 1);
|
||||
expect(published).toHaveLength(eventCount);
|
||||
});
|
||||
|
||||
it("isolates authoritative recording from a failing subscriber", () => {
|
||||
const published: number[] = [];
|
||||
const unsubscribeFailing = subscribeCombatEvents(() => {
|
||||
throw new Error("metrics consumer failed");
|
||||
});
|
||||
const unsubscribeHealthy = subscribeCombatEvents((event) => published.push(event.id));
|
||||
|
||||
try {
|
||||
expect(appendCombatEvent([], eventInput(1), 1)).toHaveLength(1);
|
||||
expect(published).toEqual([1]);
|
||||
} finally {
|
||||
unsubscribeHealthy();
|
||||
unsubscribeFailing();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
interface CombatSimulationTimerState {
|
||||
readonly sharedOnline: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly companionOpen: boolean;
|
||||
readonly documentHidden: boolean;
|
||||
}
|
||||
|
||||
export function combatSimulationTimerShouldRun({
|
||||
sharedOnline,
|
||||
paused,
|
||||
companionOpen,
|
||||
documentHidden,
|
||||
}: CombatSimulationTimerState): boolean {
|
||||
return sharedOnline || (!paused && !companionOpen && !documentHidden);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
|
||||
function allocateTalentRanks(name: string, ranks: number): void {
|
||||
const talent = TALENT_NODES.find((node) => node.name === name);
|
||||
@@ -843,6 +844,7 @@ describe("combat store", () => {
|
||||
|
||||
it("allocates the first WotLK talent point at level 10 and clamps settings", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0);
|
||||
const holy = talentNodesForTree("priest-holy")[0];
|
||||
const shadow = talentNodesForTree("priest-shadow")[0];
|
||||
expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true);
|
||||
@@ -869,12 +871,44 @@ describe("combat store", () => {
|
||||
showMobAggroRanges: true,
|
||||
threatMeterPosition: { x: 0, y: 1 },
|
||||
});
|
||||
useCombatStore.getState().updateSettings({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest" });
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
settings: { masterVolume: 0.6 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
||||
});
|
||||
|
||||
it("does not notify subscribers for unchanged positions or settings", () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = useCombatStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: DEFAULT_GAMEPLAY_SETTINGS.masterVolume,
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_SETTINGS.gameplayFrameRate,
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(notifications).toBe(0);
|
||||
|
||||
useCombatStore.getState().updateSettings({ gameplayFrameRate: 30 });
|
||||
expect(notifications).toBe(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps a CoA tree ability uncastable until its exact node is purchased", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 });
|
||||
const ability = abilitiesForClass("stormbringer").find((candidate) => (
|
||||
@@ -897,6 +931,40 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 3_000).reason).not.toBe("not-learned");
|
||||
});
|
||||
|
||||
it("breaks recursive hit-proc spell cycles in the canonical Felsworn tank build", async () => {
|
||||
const build = (await loadCanonicalBenchmarkBuilds(60, ["felsworn"]))
|
||||
.find((candidate) => candidate.role === "tank")!;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: build.classId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
});
|
||||
registerTarget("felsworn-proc-dummy", [10, 0, 0], 1_000_000, 0);
|
||||
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Doomstride (Aura 42)" }),
|
||||
}));
|
||||
const result = useCombatStore.getState().castAbility(
|
||||
"felsworn-sargeron-smite",
|
||||
[0, 0, 0],
|
||||
1,
|
||||
);
|
||||
const state = useCombatStore.getState();
|
||||
const damage = 1_000_000 - state.mobs["felsworn-proc-dummy"].health;
|
||||
const damageAbilityIds = state.combatEvents
|
||||
.filter((event) => event.kind === "damage")
|
||||
.map((event) => event.abilityId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(damage).toBeGreaterThan(0);
|
||||
expect(damage).toBeLessThan(100_000);
|
||||
expect(damageAbilityIds.filter((abilityId) => abilityId === "coa-triggered-807962"))
|
||||
.toHaveLength(1);
|
||||
expect(state.auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Felwracked (Aura 4)" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("resets encounter health without erasing earned progression", () => {
|
||||
registerTarget("mob", [2, 0, 0], 1, 100);
|
||||
useCombatStore.getState().damageMob("mob", 5, 1000);
|
||||
|
||||
+124
-47
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { combatNow } from "./combatClock";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
@@ -212,6 +213,14 @@ import {
|
||||
normalizeHudPosition,
|
||||
type NormalizedHudPosition,
|
||||
} from "./hudLayout";
|
||||
import {
|
||||
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
export type MinimapRotation = "north-up" | "player-up";
|
||||
|
||||
@@ -227,6 +236,8 @@ export interface GameplaySettings {
|
||||
readonly masterVolume: number;
|
||||
readonly uiScale: number;
|
||||
readonly minimapRotation: MinimapRotation;
|
||||
readonly gameplayFrameRate: GameplayFrameRateLimit;
|
||||
readonly previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
@@ -238,9 +249,11 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
showFloatingCombatText: true,
|
||||
autoTargetNearest: true,
|
||||
enableScreenShake: true,
|
||||
masterVolume: 0.8,
|
||||
masterVolume: 0,
|
||||
uiScale: 1,
|
||||
minimapRotation: "player-up",
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
});
|
||||
|
||||
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
||||
@@ -252,6 +265,8 @@ export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
||||
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
||||
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
||||
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
||||
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
|
||||
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
|
||||
|
||||
export interface MobStatus {
|
||||
readonly kind: MobStatusKind;
|
||||
@@ -727,6 +742,8 @@ interface MutableCastWork {
|
||||
gearStats: ItemStats;
|
||||
raceId: RaceId;
|
||||
secondaryClassId: ClassId | null;
|
||||
/** Active synchronous spell/source/target keys used to break proc cycles. */
|
||||
triggeredSpellStack?: string[];
|
||||
}
|
||||
|
||||
let feedbackSequence = 0;
|
||||
@@ -1600,9 +1617,34 @@ function normalizedSettings(
|
||||
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
||||
? patch.minimapRotation
|
||||
: current.minimapRotation,
|
||||
gameplayFrameRate: normalizeGameplayFrameRateLimit(
|
||||
patch.gameplayFrameRate,
|
||||
current.gameplayFrameRate,
|
||||
),
|
||||
previewFrameRate: normalizePreviewFrameRateLimit(
|
||||
patch.previewFrameRate,
|
||||
current.previewFrameRate,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
|
||||
return left.showMobHealthBars === right.showMobHealthBars
|
||||
&& left.showMobAggroRanges === right.showMobAggroRanges
|
||||
&& left.showTargetFrame === right.showTargetFrame
|
||||
&& left.showThreatMeter === right.showThreatMeter
|
||||
&& left.threatMeterPosition.x === right.threatMeterPosition.x
|
||||
&& left.threatMeterPosition.y === right.threatMeterPosition.y
|
||||
&& left.showFloatingCombatText === right.showFloatingCombatText
|
||||
&& left.autoTargetNearest === right.autoTargetNearest
|
||||
&& left.enableScreenShake === right.enableScreenShake
|
||||
&& left.masterVolume === right.masterVolume
|
||||
&& left.uiScale === right.uiScale
|
||||
&& left.minimapRotation === right.minimapRotation
|
||||
&& left.gameplayFrameRate === right.gameplayFrameRate
|
||||
&& left.previewFrameRate === right.previewFrameRate;
|
||||
}
|
||||
|
||||
function defaultMobStats(
|
||||
playerLevel: number,
|
||||
boss: boolean,
|
||||
@@ -1829,6 +1871,7 @@ function auraDefinitionForAbility(
|
||||
): AuraDefinition {
|
||||
return {
|
||||
...definition,
|
||||
description: definition.description ?? ability.description,
|
||||
icon: definition.icon ?? ability.icon,
|
||||
durationMs,
|
||||
};
|
||||
@@ -1974,15 +2017,30 @@ function executeTriggeredAuraProcsInWork(
|
||||
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
||||
&& typeof action.data?.spellId === "number"
|
||||
) {
|
||||
executeTriggeredSpellInWork(
|
||||
work,
|
||||
action.data.spellId,
|
||||
triggered.sourceId,
|
||||
triggered.event.otherId ?? triggered.targetId,
|
||||
now,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
const spellId = action.data.spellId;
|
||||
const targetId = triggered.event.otherId ?? triggered.targetId;
|
||||
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
|
||||
const triggeredSpellStack = work.triggeredSpellStack ?? [];
|
||||
if (
|
||||
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|
||||
|| triggeredSpellStack.includes(triggerKey)
|
||||
) continue;
|
||||
triggeredSpellStack.push(triggerKey);
|
||||
work.triggeredSpellStack = triggeredSpellStack;
|
||||
try {
|
||||
executeTriggeredSpellInWork(
|
||||
work,
|
||||
spellId,
|
||||
triggered.sourceId,
|
||||
targetId,
|
||||
now,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
triggeredSpellStack.pop();
|
||||
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
|
||||
}
|
||||
} else if (
|
||||
action.kind === "custom"
|
||||
&& action.id === "classic-status"
|
||||
@@ -2140,7 +2198,7 @@ function grantMobExperience(
|
||||
work.raceId,
|
||||
work.secondaryClassId,
|
||||
);
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, Date.now());
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
||||
}
|
||||
return scaledAward;
|
||||
}
|
||||
@@ -2696,6 +2754,8 @@ function executeTriggeredSpellInWork(
|
||||
definition: {
|
||||
id: `coa-triggered-shield:${spellId}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: effect.durationMs,
|
||||
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
|
||||
@@ -3152,7 +3212,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
resourceName: resource.name,
|
||||
abilities,
|
||||
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
||||
talentRanks,
|
||||
talentModifiers,
|
||||
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
||||
@@ -3338,7 +3398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
lootMob: (id, now = Date.now()) => {
|
||||
lootMob: (id, now = combatNow()) => {
|
||||
const mob = get().mobs[id];
|
||||
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
||||
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
||||
@@ -3562,12 +3622,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
|
||||
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
||||
));
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
|
||||
const positionUnchanged = (
|
||||
(next[0] - state.playerPosition[0]) ** 2
|
||||
+ (next[1] - state.playerPosition[1]) ** 2
|
||||
+ (next[2] - state.playerPosition[2]) ** 2
|
||||
) <= 1e-8;
|
||||
if (positionUnchanged && !movedWhileCasting) return state;
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
|
||||
return {
|
||||
playerPosition: next,
|
||||
activeCast: movedWhileCasting ? null : cast,
|
||||
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -3648,7 +3714,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
confirmGroundTarget: (position, now = Date.now()) => {
|
||||
confirmGroundTarget: (position, now = combatNow()) => {
|
||||
const pending = get().pendingGroundTarget;
|
||||
if (!pending) return emptyCastResult("invalid-destination");
|
||||
const destination = position ?? pending.position;
|
||||
@@ -3691,7 +3757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = Date.now(), legacyCompletingCast) => {
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
|
||||
const request: CastRequest = Array.isArray(requestOrOrigin)
|
||||
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
|
||||
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
|
||||
@@ -4425,6 +4491,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
@@ -5090,7 +5157,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return get().castAbility(abilityId, origin, now);
|
||||
},
|
||||
|
||||
damageMob: (id, amount, now = Date.now(), source, school = "physical", rollContext) => {
|
||||
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
|
||||
const state = get();
|
||||
const threatSource = source ?? playerThreatSource(state);
|
||||
const progression = progressionFromState(state);
|
||||
@@ -5206,7 +5273,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return { mobs };
|
||||
}),
|
||||
|
||||
tauntMob: (id, source, durationMs, now = Date.now()) => {
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
@@ -5257,7 +5324,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return effectiveDurationMs > 0;
|
||||
},
|
||||
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = Date.now()) => {
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
|
||||
let interrupted = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5273,7 +5340,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return interrupted;
|
||||
},
|
||||
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = Date.now()) => {
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
|
||||
let controlled = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5331,7 +5398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
healActor: (id, amount, request) => {
|
||||
const now = request.now ?? Date.now();
|
||||
const now = request.now ?? combatNow();
|
||||
const state = get();
|
||||
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
|
||||
const targetAuras = aurasForEntity(state.auras, id);
|
||||
@@ -5414,7 +5481,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return resolution;
|
||||
},
|
||||
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = Date.now()) => {
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const fraction = clamp(percentMaxHealth, 0.01, 1);
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
@@ -5494,7 +5561,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = Date.now()) => {
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const actor = state.actors[id];
|
||||
if (!actor) return 0;
|
||||
@@ -5533,7 +5600,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = Date.now()) => {
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
|
||||
const state = get();
|
||||
const absorb = Math.max(0, Math.round(amount));
|
||||
if (absorb <= 0 || !state.actors[id]) return 0;
|
||||
@@ -5541,6 +5608,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${sourceActorId}:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: Math.max(1, durationMs),
|
||||
@@ -5570,7 +5638,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return absorb;
|
||||
},
|
||||
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
||||
const durationMs = effect.aura.durationMs;
|
||||
@@ -5600,7 +5668,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) return false;
|
||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||
@@ -5637,7 +5705,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = Date.now()) => {
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
|
||||
const state = get();
|
||||
const owner = state.actors[ownerActorId];
|
||||
if (!owner?.alive) return null;
|
||||
@@ -5671,7 +5739,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return id;
|
||||
},
|
||||
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = Date.now()) => {
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
|
||||
const state = get();
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
if (!sourceActor?.alive) return null;
|
||||
@@ -5716,7 +5784,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
const now = combatNow();
|
||||
const mitigated = damageAfterEquipmentMitigation(
|
||||
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
||||
state.gearStats,
|
||||
@@ -5775,6 +5843,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: "rom-state-revival-charge",
|
||||
name: "Revival",
|
||||
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
maxStacks: 10,
|
||||
@@ -5831,7 +5900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: id,
|
||||
amount: requested,
|
||||
school: "physical",
|
||||
now: Date.now(),
|
||||
now: combatNow(),
|
||||
});
|
||||
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
||||
const health = summon.health - applied;
|
||||
@@ -5846,7 +5915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
summons,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "damage",
|
||||
sourceActorId: "external",
|
||||
targetActorId: id,
|
||||
@@ -5878,12 +5947,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
subjectId: PLAYER_AURA_ENTITY_ID,
|
||||
otherId: PLAYER_AURA_ENTITY_ID,
|
||||
amount: healed,
|
||||
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
set({
|
||||
auras: procResult.auras,
|
||||
health: state.health + healed,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "healing",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5920,7 +5989,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
set((state) => ({
|
||||
activeCast: null,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5936,11 +6005,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
||||
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
||||
}));
|
||||
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
|
||||
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
|
||||
return true;
|
||||
},
|
||||
|
||||
engageNearbyMobs: (now = Date.now(), playerPosition) => {
|
||||
engageNearbyMobs: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -5960,7 +6029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = Date.now(), playerPosition) => {
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -6237,6 +6306,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
||||
name: `${attack.name}: ${effect.mechanic}`,
|
||||
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
|
||||
disposition: "debuff",
|
||||
durationMs: effect.durationMs,
|
||||
dispelCategory: "magic",
|
||||
@@ -6828,7 +6898,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return appliedDamage;
|
||||
},
|
||||
|
||||
tick: (deltaSeconds, now = Date.now()) => {
|
||||
tick: (deltaSeconds, now = combatNow()) => {
|
||||
const partyHotTicks: Array<{
|
||||
memberId: string;
|
||||
amount: number;
|
||||
@@ -7687,7 +7757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
selectedTargetId: null,
|
||||
selectedFriendlyActorId: null,
|
||||
effects: [],
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
||||
summons: [],
|
||||
areaEffects: [],
|
||||
pendingGroundTarget: null,
|
||||
@@ -7746,7 +7816,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
||||
});
|
||||
return true;
|
||||
@@ -7778,7 +7848,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Talent points reset."),
|
||||
};
|
||||
}),
|
||||
@@ -7804,8 +7874,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
updateSettings: (patch) => set((state) => ({ settings: normalizedSettings(state.settings, patch) })),
|
||||
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }),
|
||||
updateSettings: (patch) => set((state) => {
|
||||
const settings = normalizedSettings(state.settings, patch);
|
||||
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
||||
}),
|
||||
resetSettings: () => set((state) => (
|
||||
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
||||
? state
|
||||
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
||||
)),
|
||||
}));
|
||||
|
||||
export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
@@ -7815,14 +7892,14 @@ export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
export function cooldownRemainingMs(
|
||||
state: Pick<CombatState, "cooldowns">,
|
||||
abilityId: string,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
||||
}
|
||||
|
||||
export function globalCooldownRemainingMs(
|
||||
state: Pick<CombatState, "globalCooldownEndsAt">,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, state.globalCooldownEndsAt - now);
|
||||
}
|
||||
@@ -7842,7 +7919,7 @@ useCombatStore.subscribe((state) => {
|
||||
collectingBossLoot = true;
|
||||
try {
|
||||
for (const boss of lootableBosses) {
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now());
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
|
||||
}
|
||||
} finally {
|
||||
collectingBossLoot = false;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
numericFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
describe("frame-rate settings", () => {
|
||||
it("normalizes persisted gameplay and preview limits", () => {
|
||||
expect(normalizeGameplayFrameRateLimit(30)).toBe(30);
|
||||
expect(normalizeGameplayFrameRateLimit("display")).toBe("display");
|
||||
expect(normalizeGameplayFrameRateLimit(144)).toBe(60);
|
||||
expect(normalizePreviewFrameRateLimit(15)).toBe(15);
|
||||
expect(normalizePreviewFrameRateLimit("invalid", 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("cycles through each menu order", () => {
|
||||
expect(nextFrameRateLimit(30, GAMEPLAY_FRAME_RATE_LIMITS)).toBe(60);
|
||||
expect(nextFrameRateLimit("display", GAMEPLAY_FRAME_RATE_LIMITS)).toBe(30);
|
||||
expect(nextFrameRateLimit(30, PREVIEW_FRAME_RATE_LIMITS)).toBe(60);
|
||||
});
|
||||
|
||||
it("formats and resolves capped versus display-refresh modes", () => {
|
||||
expect(frameRateLimitLabel(60)).toBe("60 FPS");
|
||||
expect(frameRateLimitLabel("display")).toBe("Display refresh");
|
||||
expect(numericFrameRateLimit(30)).toBe(30);
|
||||
expect(numericFrameRateLimit("display")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type GameplayFrameRateLimit = 30 | 60 | "display";
|
||||
export type PreviewFrameRateLimit = 15 | 30 | 60 | "display";
|
||||
|
||||
export const GAMEPLAY_FRAME_RATE_LIMITS: readonly GameplayFrameRateLimit[] = [30, 60, "display"];
|
||||
export const PREVIEW_FRAME_RATE_LIMITS: readonly PreviewFrameRateLimit[] = [15, 30, 60, "display"];
|
||||
|
||||
export const DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT: GameplayFrameRateLimit = 60;
|
||||
export const DEFAULT_PREVIEW_FRAME_RATE_LIMIT: PreviewFrameRateLimit = 30;
|
||||
|
||||
export function normalizeGameplayFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: GameplayFrameRateLimit = DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
): GameplayFrameRateLimit {
|
||||
return value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizePreviewFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: PreviewFrameRateLimit = DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
): PreviewFrameRateLimit {
|
||||
return value === 15 || value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function nextFrameRateLimit<T extends number | "display">(
|
||||
current: T,
|
||||
order: readonly T[],
|
||||
): T {
|
||||
const index = order.indexOf(current);
|
||||
return order[(index + 1) % order.length] ?? order[0] ?? current;
|
||||
}
|
||||
|
||||
export function frameRateLimitLabel(limit: number | "display"): string {
|
||||
return limit === "display" ? "Display refresh" : `${limit} FPS`;
|
||||
}
|
||||
|
||||
export function numericFrameRateLimit(limit: number | "display"): number | null {
|
||||
return limit === "display" ? null : limit;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emitControllerToken,
|
||||
orderedControllerTokenTransitions,
|
||||
resetControllerState,
|
||||
startControllerInput,
|
||||
subscribeControllerToken,
|
||||
type ControllerTokenEvent,
|
||||
} from "./controller";
|
||||
@@ -67,4 +68,85 @@ describe("controller button phases", () => {
|
||||
{ token: "Button0", repeat: false, pressed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses low-rate discovery until a gamepad connects and suspends while hidden", () => {
|
||||
vi.useFakeTimers();
|
||||
const windowTarget = new EventTarget() as unknown as Window & typeof globalThis;
|
||||
const documentTarget = new EventTarget() as unknown as Document;
|
||||
let visibilityState: DocumentVisibilityState = "visible";
|
||||
let focused = true;
|
||||
Object.defineProperty(documentTarget, "visibilityState", { get: () => visibilityState });
|
||||
Object.defineProperty(documentTarget, "hasFocus", { value: () => focused });
|
||||
|
||||
let gamepads: Array<Gamepad | null> = [];
|
||||
const getGamepads = vi.fn(() => gamepads as unknown as Gamepad[]);
|
||||
const frameCallbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
frameCallbacks.set(frame, callback);
|
||||
return frame;
|
||||
});
|
||||
const cancelFrame = vi.fn((frame: number) => frameCallbacks.delete(frame));
|
||||
const runFrames = (now: number) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
for (const callback of callbacks) callback(now);
|
||||
};
|
||||
|
||||
vi.stubGlobal("window", windowTarget);
|
||||
vi.stubGlobal("document", documentTarget);
|
||||
vi.stubGlobal("navigator", { getGamepads });
|
||||
vi.stubGlobal("requestAnimationFrame", requestFrame);
|
||||
vi.stubGlobal("cancelAnimationFrame", cancelFrame);
|
||||
|
||||
const stop = startControllerInput();
|
||||
try {
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(0);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(1);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(500);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(2);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
gamepads = [{
|
||||
axes: [0, 0, 0, 0],
|
||||
buttons: [],
|
||||
index: 0,
|
||||
} as unknown as Gamepad];
|
||||
windowTarget.dispatchEvent(new Event("gamepadconnected"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(501);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(3);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(517);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
visibilityState = "hidden";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
|
||||
visibilityState = "visible";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
focused = false;
|
||||
windowTarget.dispatchEvent(new Event("blur"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
} finally {
|
||||
stop();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+120
-27
@@ -25,6 +25,8 @@ const INITIAL_REPEAT_MS = 280;
|
||||
const REPEAT_MS = 90;
|
||||
const AXIS_THRESHOLD = 0.55;
|
||||
const AXIS_STEPS = 128;
|
||||
const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
|
||||
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
|
||||
|
||||
const listeners = new Set<TokenListener>();
|
||||
const movementListeners = new Set<MovementListener>();
|
||||
@@ -39,26 +41,38 @@ let stopService: (() => void) | null = null;
|
||||
function connectedGamepad(): Gamepad | null {
|
||||
const gamepads = navigator.getGamepads?.();
|
||||
if (!gamepads) return null;
|
||||
return Array.from(gamepads).find((gamepad): gamepad is Gamepad => gamepad !== null) ?? null;
|
||||
for (let index = 0; index < gamepads.length; index += 1) {
|
||||
const gamepad = gamepads[index];
|
||||
if (gamepad) return gamepad;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokensFor(gamepad: Gamepad, tokens: Set<string>): void {
|
||||
tokens.clear();
|
||||
gamepad.buttons.forEach((button, index) => {
|
||||
for (let index = 0; index < gamepad.buttons.length; index += 1) {
|
||||
const button = gamepad.buttons[index];
|
||||
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`);
|
||||
});
|
||||
gamepad.axes.forEach((value, index) => {
|
||||
}
|
||||
for (let index = 0; index < gamepad.axes.length; index += 1) {
|
||||
const value = gamepad.axes[index];
|
||||
if (value <= -AXIS_THRESHOLD) tokens.add(`Axis${index}-`);
|
||||
if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canRepeat(token: string): boolean {
|
||||
return ["Button12", "Button13", "Button14", "Button15"].includes(token)
|
||||
return REPEATABLE_BUTTON_TOKENS.has(token)
|
||||
|| token.startsWith("Axis0")
|
||||
|| token.startsWith("Axis1");
|
||||
}
|
||||
|
||||
function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const token of left) if (!right.has(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLayerModifier(token: string): boolean {
|
||||
return token === "Button4" || token === "Button6";
|
||||
}
|
||||
@@ -114,14 +128,17 @@ function normalizedAxis(value: number): number {
|
||||
}
|
||||
|
||||
export function setExternalControllerMovement(next: ControllerMovement): void {
|
||||
const normalized = {
|
||||
moveX: normalizedAxis(next.moveX),
|
||||
moveY: normalizedAxis(next.moveY),
|
||||
lookX: normalizedAxis(next.lookX),
|
||||
lookY: normalizedAxis(next.lookY),
|
||||
};
|
||||
if (Object.keys(normalized).every((key) => normalized[key as keyof ControllerMovement] === movement[key as keyof ControllerMovement])) return;
|
||||
movement = normalized;
|
||||
const moveX = normalizedAxis(next.moveX);
|
||||
const moveY = normalizedAxis(next.moveY);
|
||||
const lookX = normalizedAxis(next.lookX);
|
||||
const lookY = normalizedAxis(next.lookY);
|
||||
if (
|
||||
moveX === movement.moveX
|
||||
&& moveY === movement.moveY
|
||||
&& lookX === movement.lookX
|
||||
&& lookY === movement.lookY
|
||||
) return;
|
||||
movement = { moveX, moveY, lookX, lookY };
|
||||
for (const listener of movementListeners) listener(movement);
|
||||
}
|
||||
|
||||
@@ -141,6 +158,8 @@ export function resetControllerState(): void {
|
||||
export function startControllerInput(): () => void {
|
||||
if (stopService) return stopService;
|
||||
|
||||
const webGamepadPolling = !Capacitor.isNativePlatform();
|
||||
|
||||
const onNativeToken = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
|
||||
if (!detail?.token) return;
|
||||
@@ -157,25 +176,63 @@ export function startControllerInput(): () => void {
|
||||
if (detail) setExternalControllerMovement(detail);
|
||||
};
|
||||
const onReset = () => resetControllerState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible") resetControllerState();
|
||||
};
|
||||
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.addEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let frame = 0;
|
||||
let discoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hadGamepad = false;
|
||||
let activeGamepadIndex: number | null = null;
|
||||
|
||||
const canPollGamepads = () => document.visibilityState === "visible"
|
||||
&& (typeof document.hasFocus !== "function" || document.hasFocus());
|
||||
|
||||
const cancelScheduledPoll = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
};
|
||||
|
||||
const suspendPolling = () => {
|
||||
cancelScheduledPoll();
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
};
|
||||
|
||||
const scheduleFrame = () => {
|
||||
if (!webGamepadPolling || frame || !canPollGamepads()) return;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
const scheduleDiscovery = () => {
|
||||
if (!webGamepadPolling || frame || discoveryTimer !== null || !canPollGamepads()) return;
|
||||
discoveryTimer = setTimeout(() => {
|
||||
discoveryTimer = null;
|
||||
scheduleFrame();
|
||||
}, GAMEPAD_DISCOVERY_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const poll = (now: number) => {
|
||||
const gamepad = typeof document.hasFocus === "function" && !document.hasFocus()
|
||||
? null
|
||||
: connectedGamepad();
|
||||
frame = 0;
|
||||
if (!canPollGamepads()) {
|
||||
suspendPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const gamepad = connectedGamepad();
|
||||
if (!gamepad) {
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
scheduleDiscovery();
|
||||
} else {
|
||||
hadGamepad = true;
|
||||
activeGamepadIndex = gamepad.index;
|
||||
setExternalControllerMovement({
|
||||
moveX: gamepad.axes[0] ?? 0,
|
||||
moveY: gamepad.axes[1] ?? 0,
|
||||
@@ -183,9 +240,11 @@ export function startControllerInput(): () => void {
|
||||
lookY: gamepad.axes[3] ?? 0,
|
||||
});
|
||||
tokensFor(gamepad, currentTokens);
|
||||
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
||||
emitControllerToken(transition);
|
||||
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
||||
if (!tokenSetsMatch(previousTokens, currentTokens)) {
|
||||
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
||||
emitControllerToken(transition);
|
||||
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
||||
}
|
||||
}
|
||||
for (const token of currentTokens) {
|
||||
if (!previousTokens.has(token) || !canRepeat(token)) continue;
|
||||
@@ -199,17 +258,51 @@ export function startControllerInput(): () => void {
|
||||
const scratch = previousTokens;
|
||||
previousTokens = currentTokens;
|
||||
currentTokens = scratch;
|
||||
scheduleFrame();
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
if (!Capacitor.isNativePlatform()) frame = requestAnimationFrame(poll);
|
||||
|
||||
const onGamepadConnected = () => scheduleFrame();
|
||||
const onGamepadDisconnected = (event: Event) => {
|
||||
const disconnectedIndex = (event as GamepadEvent).gamepad?.index;
|
||||
if (disconnectedIndex === undefined || disconnectedIndex === activeGamepadIndex) {
|
||||
resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
}
|
||||
scheduleFrame();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") scheduleFrame();
|
||||
else {
|
||||
suspendPolling();
|
||||
if (!webGamepadPolling) resetControllerState();
|
||||
}
|
||||
};
|
||||
const onFocus = () => scheduleFrame();
|
||||
const onBlur = () => suspendPolling();
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
if (webGamepadPolling) {
|
||||
window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
scheduleFrame();
|
||||
}
|
||||
|
||||
stopService = () => {
|
||||
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (webGamepadPolling) {
|
||||
window.removeEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.removeEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
}
|
||||
cancelScheduledPoll();
|
||||
resetControllerState();
|
||||
stopService = null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function documentIsVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether rendering work can produce a visible frame. Page lifecycle
|
||||
* events cover native/WebView transitions where visibilityState can lag.
|
||||
*/
|
||||
export function useDocumentVisibility(): boolean {
|
||||
const [visible, setVisible] = useState(documentIsVisible);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setVisible(documentIsVisible());
|
||||
const hide = () => setVisible(false);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
window.addEventListener("pageshow", refresh);
|
||||
window.addEventListener("pagehide", hide);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
window.removeEventListener("pageshow", refresh);
|
||||
window.removeEventListener("pagehide", hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
|
||||
import {
|
||||
dprForPerformanceFactor,
|
||||
performanceBoundsForFrameRate,
|
||||
} from "./adaptiveGraphicsQualityMath";
|
||||
|
||||
describe("adaptive graphics quality", () => {
|
||||
it("maps the performance range onto the supported pixel ratio", () => {
|
||||
@@ -12,4 +15,11 @@ describe("adaptive graphics quality", () => {
|
||||
expect(dprForPerformanceFactor(-1)).toBe(1);
|
||||
expect(dprForPerformanceFactor(2)).toBe(1.5);
|
||||
});
|
||||
|
||||
it("uses bounds that match an intentional frame-rate cap", () => {
|
||||
expect(performanceBoundsForFrameRate(30, 144)).toEqual([26, 29]);
|
||||
expect(performanceBoundsForFrameRate(60, 144)).toEqual([52, 58]);
|
||||
expect(performanceBoundsForFrameRate("display", 144)).toEqual([60, 100]);
|
||||
expect(performanceBoundsForFrameRate("display", 60)).toEqual([52, 58]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { PerformanceMonitor } from "@react-three/drei";
|
||||
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
import {
|
||||
dprForPerformanceFactor,
|
||||
performanceBoundsForFrameRate,
|
||||
} from "./adaptiveGraphicsQualityMath";
|
||||
|
||||
export function AdaptiveGraphicsQuality({
|
||||
frameRateLimit,
|
||||
onDprChange,
|
||||
}: {
|
||||
readonly frameRateLimit: GameplayFrameRateLimit;
|
||||
readonly onDprChange: (dpr: number) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -13,7 +19,7 @@ export function AdaptiveGraphicsQuality({
|
||||
ms={250}
|
||||
step={0.25}
|
||||
threshold={0.75}
|
||||
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]}
|
||||
bounds={(refreshRate) => performanceBoundsForFrameRate(frameRateLimit, refreshRate)}
|
||||
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
|
||||
|
||||
export function CappedFrameLoop({
|
||||
active = true,
|
||||
targetFps,
|
||||
}: {
|
||||
readonly active?: boolean;
|
||||
readonly targetFps: number;
|
||||
}) {
|
||||
const advance = useThree((state) => state.advance);
|
||||
const clock = useThree((state) => state.clock);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return startCappedFrameLoop({
|
||||
targetFps,
|
||||
getElapsedSeconds: () => clock.elapsedTime,
|
||||
advance,
|
||||
});
|
||||
}, [active, advance, clock, targetFps]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+41
-27
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
@@ -19,34 +18,32 @@ import {
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
playCombatAudio,
|
||||
resetCombatAudio,
|
||||
setCombatAudioListener,
|
||||
stopCombatAudioGroup,
|
||||
} from "../game/combatAudio";
|
||||
|
||||
interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
}
|
||||
import {
|
||||
advanceCombatEffectFrame,
|
||||
capCombatEffects,
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
|
||||
export interface CombatEffectsProps {
|
||||
readonly active: boolean;
|
||||
readonly safeGraphics?: boolean;
|
||||
}
|
||||
|
||||
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
off: 0,
|
||||
low: 24,
|
||||
high: 64,
|
||||
};
|
||||
|
||||
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
|
||||
low: 48,
|
||||
high: 128,
|
||||
};
|
||||
|
||||
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
|
||||
|
||||
function effectDurationMs(event: CombatPresentationEvent): number {
|
||||
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
|
||||
if (event.phase === "tick") return 320;
|
||||
@@ -77,7 +74,7 @@ function deterministicParticles(seed: number, count: number): Float32Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function CombatEffectPrimitive({
|
||||
const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
@@ -119,15 +116,16 @@ function CombatEffectPrimitive({
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current || !groupRef.current) return;
|
||||
elapsedRef.current += Math.min(0.05, delta) * 1_000;
|
||||
const progress = Math.min(1, elapsedRef.current / durationMs);
|
||||
const frame = advanceCombatEffectFrame(elapsedRef.current, delta, durationMs);
|
||||
elapsedRef.current = frame.elapsedMs;
|
||||
const progress = frame.progress;
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
if (projectile) {
|
||||
groupRef.current.position.lerpVectors(origin, target, eased);
|
||||
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
|
||||
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
|
||||
} else {
|
||||
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
|
||||
groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8);
|
||||
const scale = event.phase === "cast-start"
|
||||
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
|
||||
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
|
||||
@@ -140,7 +138,7 @@ function CombatEffectPrimitive({
|
||||
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
|
||||
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
|
||||
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
|
||||
if (progress >= 1) {
|
||||
if (frame.done) {
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
}
|
||||
@@ -234,12 +232,13 @@ function CombatEffectPrimitive({
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
{quality === "high" && event.school !== "physical" ? (
|
||||
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
}, (previous, next) => (
|
||||
previous.event === next.event
|
||||
&& previous.quality === next.quality
|
||||
&& previous.active === next.active
|
||||
));
|
||||
|
||||
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
|
||||
const settings = useShellStore((state) => state.combatPresentationSettings);
|
||||
@@ -253,6 +252,21 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
const qualityRef = useRef(effectiveQuality);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const forward = useMemo(() => new Vector3(), []);
|
||||
const audioOutputEnabled = combatAudioOutputEnabled(settings, masterVolume);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
|
||||
quality: effectiveQuality,
|
||||
systems: effects.length,
|
||||
activeLights: 0,
|
||||
allocatedLights: 0,
|
||||
});
|
||||
}, [effectiveQuality, effects.length]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
@@ -260,11 +274,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
configureCombatAudio(settings, masterVolume);
|
||||
if (
|
||||
qualityRef.current !== "off"
|
||||
|| Object.values(settings.audioEnabled).some(Boolean)
|
||||
|| combatAudioOutputEnabled(settings, masterVolume)
|
||||
) void preloadCombatEffectManifest();
|
||||
setEffects((current) => current.filter(({ event }) => (
|
||||
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
|
||||
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
|
||||
)));
|
||||
)), qualityRef.current));
|
||||
for (const group of ["player", "party", "enemy"] as const) {
|
||||
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
|
||||
}
|
||||
@@ -298,14 +312,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
|| quality === "off"
|
||||
|| !presentationSettings.vfxEnabled[event.actorGroup]
|
||||
) return withoutCast;
|
||||
const limit = QUALITY_SYSTEM_LIMIT[quality];
|
||||
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
|
||||
return updateCombatEffectQueue(withoutCast, event, quality);
|
||||
});
|
||||
}), []);
|
||||
|
||||
useEffect(() => () => resetCombatAudio(), []);
|
||||
|
||||
useFrame(() => {
|
||||
if (!active || !audioOutputEnabled) return;
|
||||
camera.getWorldDirection(forward);
|
||||
setCombatAudioListener(
|
||||
[camera.position.x, camera.position.y, camera.position.z],
|
||||
|
||||
+24
-1
@@ -47,6 +47,10 @@ import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
|
||||
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
|
||||
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { numericFrameRateLimit } from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "./CappedFrameLoop";
|
||||
|
||||
export function GameScene() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -95,8 +99,10 @@ export function GameScene() {
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const mapOpen = useGameStore((state) => state.mapOpen);
|
||||
const companionOpen = useGameStore((state) => state.companionOpen);
|
||||
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
|
||||
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
|
||||
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
|
||||
const [safeGraphics, setSafeGraphics] = useState(false);
|
||||
@@ -106,6 +112,7 @@ export function GameScene() {
|
||||
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
|
||||
const simulationBlocked = graphicsRecovery !== null
|
||||
|| !worldReady
|
||||
|| (!documentVisible && !sharedOnline)
|
||||
|| (!sharedOnline && (paused || mapOpen || companionOpen));
|
||||
const frameloop = sceneFrameLoop({
|
||||
worldReady,
|
||||
@@ -114,7 +121,10 @@ export function GameScene() {
|
||||
paused,
|
||||
mapOpen,
|
||||
companionOpen,
|
||||
documentVisible,
|
||||
frameRateLimit: gameplayFrameRate,
|
||||
});
|
||||
const targetFps = numericFrameRateLimit(gameplayFrameRate);
|
||||
const allowedRuntimeIds = useMemo(
|
||||
() => gameMode === "manastorm" && manastormEncounter
|
||||
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
|
||||
@@ -204,6 +214,8 @@ export function GameScene() {
|
||||
onLostPointerCapture={endMouseLook}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
aria-label="Healer Man 3D dungeon view"
|
||||
data-frame-rate-limit={gameplayFrameRate}
|
||||
data-frame-loop={frameloop}
|
||||
>
|
||||
<Canvas
|
||||
key={rendererCanvasKey(canvasRevision)}
|
||||
@@ -226,8 +238,19 @@ export function GameScene() {
|
||||
powerPreference: safeGraphics ? "default" : "high-performance",
|
||||
}}
|
||||
>
|
||||
{frameloop === "never" && targetFps !== null && (
|
||||
<CappedFrameLoop
|
||||
active={documentVisible || sharedOnline}
|
||||
targetFps={targetFps}
|
||||
/>
|
||||
)}
|
||||
<GameGltfLoaderLifecycle>
|
||||
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />}
|
||||
{!safeGraphics && worldReady && (
|
||||
<AdaptiveGraphicsQuality
|
||||
frameRateLimit={gameplayFrameRate}
|
||||
onDprChange={setAdaptiveDpr}
|
||||
/>
|
||||
)}
|
||||
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
|
||||
<color attach="background" args={[dungeon.presentation.background]} />
|
||||
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
export function dprForPerformanceFactor(factor: number): number {
|
||||
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
|
||||
}
|
||||
|
||||
export function performanceBoundsForFrameRate(
|
||||
frameRateLimit: GameplayFrameRateLimit,
|
||||
refreshRate: number,
|
||||
): [number, number] {
|
||||
if (frameRateLimit === 30) return [26, 29];
|
||||
if (frameRateLimit === 60) return [52, 58];
|
||||
return refreshRate > 100 ? [60, 100] : [52, 58];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
|
||||
|
||||
function createFrameQueue() {
|
||||
const callbacks = new Map<number, (timestampMs: number) => void>();
|
||||
const cancelled: number[] = [];
|
||||
let nextFrame = 1;
|
||||
|
||||
return {
|
||||
callbacks,
|
||||
cancelled,
|
||||
requestFrame(callback: (timestampMs: number) => void) {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
callbacks.set(frame, callback);
|
||||
return frame;
|
||||
},
|
||||
cancelFrame(frame: number) {
|
||||
cancelled.push(frame);
|
||||
callbacks.delete(frame);
|
||||
},
|
||||
run(frame: number, timestampMs: number) {
|
||||
const callback = callbacks.get(frame);
|
||||
callbacks.delete(frame);
|
||||
callback?.(timestampMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("capped frame-loop runtime", () => {
|
||||
it("starts from Fiber's committed elapsed-seconds clock", () => {
|
||||
const queue = createFrameQueue();
|
||||
const advances: number[] = [];
|
||||
const stop = startCappedFrameLoop({
|
||||
targetFps: 30,
|
||||
getElapsedSeconds: () => 42,
|
||||
advance: (elapsedSeconds) => advances.push(elapsedSeconds),
|
||||
requestFrame: queue.requestFrame,
|
||||
cancelFrame: queue.cancelFrame,
|
||||
});
|
||||
|
||||
queue.run(1, 1_000);
|
||||
queue.run(2, 1_034);
|
||||
|
||||
expect(advances).toEqual([42, 42.034]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("does not schedule a zombie frame after re-entrant cleanup", () => {
|
||||
const queue = createFrameQueue();
|
||||
let stop: () => void = () => undefined;
|
||||
stop = startCappedFrameLoop({
|
||||
targetFps: 60,
|
||||
getElapsedSeconds: () => 0,
|
||||
advance: () => stop(),
|
||||
requestFrame: queue.requestFrame,
|
||||
cancelFrame: queue.cancelFrame,
|
||||
});
|
||||
|
||||
queue.run(1, 500);
|
||||
|
||||
expect(queue.callbacks.size).toBe(0);
|
||||
expect(queue.cancelled).toEqual([1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
createFrameRateGovernor,
|
||||
stepFrameRateGovernor,
|
||||
} from "./frameRateGovernor";
|
||||
|
||||
interface CappedFrameLoopRuntimeOptions {
|
||||
readonly targetFps: number;
|
||||
readonly getElapsedSeconds: () => number;
|
||||
readonly advance: (elapsedSeconds: number) => void;
|
||||
readonly requestFrame?: (callback: (timestampMs: number) => void) => number;
|
||||
readonly cancelFrame?: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function startCappedFrameLoop({
|
||||
targetFps,
|
||||
getElapsedSeconds,
|
||||
advance,
|
||||
requestFrame = requestAnimationFrame,
|
||||
cancelFrame = cancelAnimationFrame,
|
||||
}: CappedFrameLoopRuntimeOptions): () => void {
|
||||
let frame = 0;
|
||||
let cancelled = false;
|
||||
let governor = createFrameRateGovernor(targetFps);
|
||||
let elapsedBase: number | null = null;
|
||||
|
||||
const renderAtTargetRate = (timestampMs: number) => {
|
||||
if (cancelled) return;
|
||||
if (elapsedBase === null) {
|
||||
// Read after the Canvas has committed its frameloop change; Fiber resets
|
||||
// the clock when entering "never" mode.
|
||||
const elapsedSeconds = getElapsedSeconds();
|
||||
elapsedBase = Number.isFinite(elapsedSeconds)
|
||||
? Math.max(0, elapsedSeconds)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const step = stepFrameRateGovernor(governor, timestampMs);
|
||||
governor = step.state;
|
||||
if (step.shouldRender) advance(elapsedBase + step.elapsedSeconds);
|
||||
if (!cancelled) frame = requestFrame(renderAtTargetRate);
|
||||
};
|
||||
|
||||
frame = requestFrame(renderAtTargetRate);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelFrame(frame);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CombatPresentationEvent } from "../game/combatPresentation";
|
||||
import {
|
||||
COMBAT_EFFECT_SYSTEM_LIMIT,
|
||||
advanceCombatEffectFrame,
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
|
||||
function event(
|
||||
id: number,
|
||||
patch: Partial<CombatPresentationEvent> = {},
|
||||
): CombatPresentationEvent {
|
||||
return {
|
||||
id,
|
||||
sessionId: 1,
|
||||
occurredAt: id,
|
||||
phase: "impact",
|
||||
actorGroup: "enemy",
|
||||
sourceActorId: `enemy-${id}`,
|
||||
targetActorId: "player",
|
||||
abilityId: "fixture",
|
||||
name: "Fixture",
|
||||
source: "fallback",
|
||||
school: "shadow",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(
|
||||
events: readonly CombatPresentationEvent[],
|
||||
quality: "off" | "low" | "high",
|
||||
): readonly ActiveCombatEffect[] {
|
||||
return events.reduce<readonly ActiveCombatEffect[]>(
|
||||
(current, next) => updateCombatEffectQueue(current, next, quality),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("combat-effect runtime budget", () => {
|
||||
it("expires effects by wall time while bounding motion after a hitch", () => {
|
||||
let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
|
||||
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
|
||||
frame = advanceCombatEffectFrame(frame.elapsedMs, 0.5, 420);
|
||||
|
||||
expect(frame.elapsedMs).toBeCloseTo(533.333, 2);
|
||||
expect(frame.motionDeltaSeconds).toBe(0.05);
|
||||
expect(frame.progress).toBe(1);
|
||||
expect(frame.done).toBe(true);
|
||||
expect(advanceCombatEffectFrame(frame.elapsedMs, Number.NaN, 420).elapsedMs)
|
||||
.toBe(frame.elapsedMs);
|
||||
});
|
||||
|
||||
it("bounds large bursts while retaining player feedback and the newest event", () => {
|
||||
const player = event(1, {
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
school: "holy",
|
||||
});
|
||||
const enemies = Array.from({ length: 512 }, (_, index) => event(index + 2, {
|
||||
phase: "tick",
|
||||
}));
|
||||
|
||||
const high = enqueue([player, ...enemies], "high");
|
||||
const low = enqueue([player, ...enemies], "low");
|
||||
|
||||
expect(high).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.high);
|
||||
expect(low).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
||||
expect(enqueue([player], "off")).toEqual([]);
|
||||
expect(updateCombatEffectQueue(enqueue([player], "high"), enemies[0], "off"))
|
||||
.toEqual([]);
|
||||
expect(high.some(({ event: active }) => active.id === player.id)).toBe(true);
|
||||
expect(high.at(-1)?.event.id).toBe(enemies.at(-1)?.id);
|
||||
expect(new Set(high.map(({ key }) => key)).size).toBe(high.length);
|
||||
});
|
||||
|
||||
it("keeps combat-significant enemy casts ahead of low-value friendly ticks", () => {
|
||||
const partyTicks = Array.from({ length: COMBAT_EFFECT_SYSTEM_LIMIT.low }, (_, index) => event(index + 1, {
|
||||
actorGroup: "party",
|
||||
sourceActorId: `party-${index}`,
|
||||
phase: "tick",
|
||||
}));
|
||||
const enemyCast = event(100, {
|
||||
actorGroup: "enemy",
|
||||
sourceActorId: "boss",
|
||||
phase: "cast-start",
|
||||
});
|
||||
|
||||
const effects = enqueue([...partyTicks, enemyCast], "low");
|
||||
expect(effects).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
||||
expect(effects.some(({ event: active }) => active.id === enemyCast.id)).toBe(true);
|
||||
expect(effects.filter(({ event: active }) => active.phase === "tick"))
|
||||
.toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low - 1);
|
||||
});
|
||||
|
||||
it("coalesces a single area burst but preserves projectile phases", () => {
|
||||
const areaRelease = event(1, {
|
||||
occurredAt: 100,
|
||||
phase: "release",
|
||||
delivery: "area",
|
||||
sourceActorId: "caster",
|
||||
});
|
||||
const areaImpacts = Array.from({ length: 5 }, (_, index) => event(index + 2, {
|
||||
occurredAt: 100,
|
||||
phase: "impact",
|
||||
delivery: "area",
|
||||
sourceActorId: "caster",
|
||||
targetActorId: `target-${index}`,
|
||||
}));
|
||||
const area = enqueue([areaRelease, ...areaImpacts], "high");
|
||||
expect(area).toHaveLength(1);
|
||||
expect(area[0].event.phase).toBe("impact");
|
||||
|
||||
const projectile = enqueue([
|
||||
event(10, { occurredAt: 200, phase: "release", sourceActorId: "caster" }),
|
||||
event(11, { occurredAt: 200, phase: "impact", sourceActorId: "caster" }),
|
||||
], "high");
|
||||
expect(projectile).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
CombatPresentationEvent,
|
||||
CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
|
||||
export interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
}
|
||||
|
||||
export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
off: 0,
|
||||
low: 24,
|
||||
high: 32,
|
||||
};
|
||||
|
||||
export interface CombatEffectFrameState {
|
||||
readonly elapsedMs: number;
|
||||
readonly motionDeltaSeconds: number;
|
||||
readonly progress: number;
|
||||
readonly done: boolean;
|
||||
}
|
||||
|
||||
export function advanceCombatEffectFrame(
|
||||
elapsedMs: number,
|
||||
rawDeltaSeconds: number,
|
||||
durationMs: number,
|
||||
): CombatEffectFrameState {
|
||||
const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0;
|
||||
const deltaSeconds = Number.isFinite(rawDeltaSeconds) ? Math.max(0, rawDeltaSeconds) : 0;
|
||||
const safeDurationMs = Number.isFinite(durationMs) ? Math.max(1, durationMs) : 1;
|
||||
// Age follows wall time so a slow frame expires work instead of extending the
|
||||
// overload. Motion remains bounded to avoid a single-frame visual jump.
|
||||
const nextElapsedMs = safeElapsedMs + deltaSeconds * 1_000;
|
||||
const progress = Math.min(1, nextElapsedMs / safeDurationMs);
|
||||
return {
|
||||
elapsedMs: nextElapsedMs,
|
||||
motionDeltaSeconds: Math.min(0.05, deltaSeconds),
|
||||
progress,
|
||||
done: progress >= 1,
|
||||
};
|
||||
}
|
||||
|
||||
function actorPriority(event: CombatPresentationEvent): number {
|
||||
const groupPriority = event.actorGroup === "player" ? 30 : event.actorGroup === "party" ? 20 : 10;
|
||||
// Preserve readable combat beats before actor ownership. An enemy cast or
|
||||
// impact is more useful than another periodic tick from any actor group.
|
||||
const phasePriority = event.phase === "cast-start"
|
||||
? 300
|
||||
: event.phase === "release" || event.phase === "impact"
|
||||
? 200
|
||||
: event.phase === "tick"
|
||||
? 0
|
||||
: 100;
|
||||
return groupPriority + phasePriority;
|
||||
}
|
||||
|
||||
function areaBurstKey(event: CombatPresentationEvent): string | null {
|
||||
if (event.delivery !== "area" || event.phase === "cast-start" || event.phase === "cast-cancel") {
|
||||
return null;
|
||||
}
|
||||
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}:${event.occurredAt}`;
|
||||
}
|
||||
|
||||
function effectKey(event: CombatPresentationEvent): string {
|
||||
return `${event.sessionId}:${event.id}`;
|
||||
}
|
||||
|
||||
function trimCombatEffects(
|
||||
effects: readonly ActiveCombatEffect[],
|
||||
limit: number,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
if (effects.length <= limit) return effects;
|
||||
const next = [...effects];
|
||||
while (next.length > limit) {
|
||||
let removalIndex = 0;
|
||||
let removalPriority = actorPriority(next[0].event);
|
||||
for (let index = 1; index < next.length; index += 1) {
|
||||
const priority = actorPriority(next[index].event);
|
||||
if (priority < removalPriority) {
|
||||
removalIndex = index;
|
||||
removalPriority = priority;
|
||||
}
|
||||
}
|
||||
next.splice(removalIndex, 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function capCombatEffects(
|
||||
effects: readonly ActiveCombatEffect[],
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
const limit = COMBAT_EFFECT_SYSTEM_LIMIT[quality];
|
||||
if (limit === 0) return effects.length === 0 ? effects : [];
|
||||
return trimCombatEffects(effects, limit);
|
||||
}
|
||||
|
||||
export function updateCombatEffectQueue(
|
||||
current: readonly ActiveCombatEffect[],
|
||||
event: CombatPresentationEvent,
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
|
||||
? current.filter((effect) => !(
|
||||
effect.event.phase === "cast-start"
|
||||
&& effect.event.sourceActorId === event.sourceActorId
|
||||
&& effect.event.abilityId === event.abilityId
|
||||
))
|
||||
: current;
|
||||
if (quality === "off") return current.length === 0 ? current : [];
|
||||
if (event.phase === "cast-cancel") {
|
||||
return withoutCast.length === current.length ? current : withoutCast;
|
||||
}
|
||||
|
||||
const burstKey = areaBurstKey(event);
|
||||
if (burstKey) {
|
||||
const existingIndex = withoutCast.findIndex((effect) => areaBurstKey(effect.event) === burstKey);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = withoutCast[existingIndex];
|
||||
if (existing.event.phase === event.phase) return withoutCast;
|
||||
const next = [...withoutCast];
|
||||
next[existingIndex] = { key: effectKey(event), event };
|
||||
return capCombatEffects(next, quality);
|
||||
}
|
||||
}
|
||||
|
||||
const next = capCombatEffects(
|
||||
[...withoutCast, { key: effectKey(event), event }],
|
||||
quality,
|
||||
);
|
||||
if (
|
||||
next.length === current.length
|
||||
&& next.every((effect, index) => effect.key === current[index]?.key)
|
||||
) return current;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFrameRateGovernor,
|
||||
resetFrameRateGovernor,
|
||||
stepFrameRateGovernor,
|
||||
} from "./frameRateGovernor";
|
||||
|
||||
function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000): number {
|
||||
let state = createFrameRateGovernor(targetFps);
|
||||
let renders = 0;
|
||||
for (let timestamp = 0; timestamp <= durationMs; timestamp += 1_000 / sourceFps) {
|
||||
const step = stepFrameRateGovernor(state, timestamp);
|
||||
state = step.state;
|
||||
if (step.shouldRender) renders += 1;
|
||||
}
|
||||
return renders;
|
||||
}
|
||||
|
||||
describe("frame-rate governor", () => {
|
||||
it("holds capped modes near their target on high-refresh displays", () => {
|
||||
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
|
||||
expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
|
||||
expect(renderCount(60, 240)).toBeGreaterThanOrEqual(60);
|
||||
expect(renderCount(60, 240)).toBeLessThanOrEqual(61);
|
||||
});
|
||||
|
||||
it("renders every source callback in display mode", () => {
|
||||
let state = createFrameRateGovernor("display", 4);
|
||||
for (const timestamp of [1_000, 1_008, 1_016]) {
|
||||
const step = stepFrameRateGovernor(state, timestamp);
|
||||
state = step.state;
|
||||
expect(step.shouldRender).toBe(true);
|
||||
}
|
||||
expect(state.elapsedSeconds).toBeCloseTo(4.016);
|
||||
});
|
||||
|
||||
it("skips missed deadlines without emitting a catch-up burst", () => {
|
||||
let state = createFrameRateGovernor(60);
|
||||
state = stepFrameRateGovernor(state, 0).state;
|
||||
const delayed = stepFrameRateGovernor(state, 100);
|
||||
expect(delayed.shouldRender).toBe(true);
|
||||
expect(delayed.state.nextDeadlineMs).toBeGreaterThan(100);
|
||||
const sameTimestamp = stepFrameRateGovernor(delayed.state, 100);
|
||||
expect(sameTimestamp.shouldRender).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves elapsed time while resetting the wall-clock baseline", () => {
|
||||
let state = createFrameRateGovernor(30, 2);
|
||||
state = stepFrameRateGovernor(state, 1_000).state;
|
||||
state = stepFrameRateGovernor(state, 1_034).state;
|
||||
const reset = resetFrameRateGovernor(state, 60);
|
||||
const resumed = stepFrameRateGovernor(reset, 90_000);
|
||||
expect(resumed.shouldRender).toBe(true);
|
||||
expect(resumed.elapsedSeconds).toBeCloseTo(state.elapsedSeconds);
|
||||
});
|
||||
|
||||
it("never moves elapsed time backwards", () => {
|
||||
let state = createFrameRateGovernor(60);
|
||||
state = stepFrameRateGovernor(state, 20).state;
|
||||
state = stepFrameRateGovernor(state, 40).state;
|
||||
const backwards = stepFrameRateGovernor(state, 10);
|
||||
expect(backwards.elapsedSeconds).toBe(state.elapsedSeconds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
export type FrameRateGovernorMode = number | "display";
|
||||
|
||||
export interface FrameRateGovernorState {
|
||||
readonly mode: FrameRateGovernorMode;
|
||||
readonly lastTimestampMs: number | null;
|
||||
readonly nextDeadlineMs: number | null;
|
||||
readonly elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface FrameRateGovernorStep {
|
||||
readonly state: FrameRateGovernorState;
|
||||
readonly shouldRender: boolean;
|
||||
readonly elapsedSeconds: number;
|
||||
}
|
||||
|
||||
function frameIntervalMs(mode: FrameRateGovernorMode): number | null {
|
||||
if (mode === "display") return null;
|
||||
return Number.isFinite(mode) && mode > 0 ? 1_000 / mode : 1_000 / 60;
|
||||
}
|
||||
|
||||
export function createFrameRateGovernor(
|
||||
mode: FrameRateGovernorMode,
|
||||
elapsedSeconds = 0,
|
||||
): FrameRateGovernorState {
|
||||
return {
|
||||
mode,
|
||||
lastTimestampMs: null,
|
||||
nextDeadlineMs: null,
|
||||
elapsedSeconds: Math.max(0, elapsedSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetFrameRateGovernor(
|
||||
state: FrameRateGovernorState,
|
||||
mode: FrameRateGovernorMode = state.mode,
|
||||
): FrameRateGovernorState {
|
||||
return createFrameRateGovernor(mode, state.elapsedSeconds);
|
||||
}
|
||||
|
||||
export function stepFrameRateGovernor(
|
||||
state: FrameRateGovernorState,
|
||||
rawTimestampMs: number,
|
||||
): FrameRateGovernorStep {
|
||||
const fallbackTimestamp = state.lastTimestampMs ?? 0;
|
||||
const finiteTimestamp = Number.isFinite(rawTimestampMs) ? rawTimestampMs : fallbackTimestamp;
|
||||
const timestampMs = Math.max(fallbackTimestamp, finiteTimestamp);
|
||||
const intervalMs = frameIntervalMs(state.mode);
|
||||
|
||||
if (state.lastTimestampMs === null) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
nextDeadlineMs: intervalMs === null ? null : timestampMs + intervalMs,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds: nextState.elapsedSeconds };
|
||||
}
|
||||
|
||||
const elapsedSeconds = state.elapsedSeconds + (timestampMs - state.lastTimestampMs) / 1_000;
|
||||
if (intervalMs === null) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds };
|
||||
}
|
||||
|
||||
const deadlineMs = state.nextDeadlineMs ?? timestampMs;
|
||||
if (timestampMs < deadlineMs) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: false, elapsedSeconds };
|
||||
}
|
||||
|
||||
const missedIntervals = Math.floor(Math.max(0, timestampMs - deadlineMs) / intervalMs);
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
nextDeadlineMs: deadlineMs + (missedIntervals + 1) * intervalMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds };
|
||||
}
|
||||
@@ -28,13 +28,18 @@ describe("R3F scene lifecycle", () => {
|
||||
paused: false,
|
||||
mapOpen: false,
|
||||
companionOpen: false,
|
||||
documentVisible: true,
|
||||
frameRateLimit: 60 as const,
|
||||
};
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always");
|
||||
expect(sceneFrameLoop(readyWorld)).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("never");
|
||||
expect(sceneFrameLoop(readyWorld)).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, frameRateLimit: "display" })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false, sharedOnline: true })).toBe("never");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import type { Frameloop } from "@react-three/fiber";
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
/**
|
||||
* Encounter transitions must never remount the Canvas: R3F intentionally loses
|
||||
@@ -41,10 +42,14 @@ export function sceneFrameLoop(options: {
|
||||
readonly paused: boolean;
|
||||
readonly mapOpen: boolean;
|
||||
readonly companionOpen: boolean;
|
||||
readonly documentVisible: boolean;
|
||||
readonly frameRateLimit: GameplayFrameRateLimit;
|
||||
}): Frameloop {
|
||||
if (!options.documentVisible && !options.sharedOnline) return "demand";
|
||||
const locallyPaused = options.worldReady
|
||||
&& !options.graphicsRecovering
|
||||
&& !options.sharedOnline
|
||||
&& (options.paused || options.mapOpen || options.companionOpen);
|
||||
return locallyPaused ? "demand" : "always";
|
||||
if (locallyPaused) return "demand";
|
||||
return options.frameRateLimit === "display" ? "always" : "never";
|
||||
}
|
||||
+45
-6
@@ -758,6 +758,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
text-align: right;
|
||||
text-shadow: 0 1px 2px #000, 0 0 3px #000;
|
||||
}
|
||||
.timed-effect--inspectable { cursor: help; transition: border-color 100ms ease, filter 100ms ease, transform 100ms ease; }
|
||||
.timed-effect--inspectable:hover, .timed-effect--inspectable:focus-visible { z-index: 2; border-color: #f4dd8c; outline: 1px solid rgba(244,221,140,.58); filter: brightness(1.16); transform: translateY(-1px); }
|
||||
.timed-effect-strip--party { min-height: 16px; margin-top: 1px; }
|
||||
.timed-effect-strip--party .timed-effect { width: 16px; height: 16px; }
|
||||
.timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; }
|
||||
@@ -774,6 +776,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
|
||||
.aura--inspectable { cursor: help; transition: border-color 100ms ease, filter 100ms ease, transform 100ms ease; }
|
||||
.aura--inspectable:hover, .aura--inspectable:focus-visible { z-index: 2; border-color: #f4dd8c; outline: 1px solid rgba(244,221,140,.58); filter: brightness(1.16); transform: translateY(-1px); }
|
||||
.aura-strip--target { justify-content: center; margin-top: 3px; }
|
||||
.aura-strip--party { min-height: 16px; margin-top: 1px; }
|
||||
.aura-strip--party .aura { width: 16px; height: 16px; }
|
||||
@@ -788,11 +792,23 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-command-bar strong { min-width: 0; overflow: hidden; font-size: 7px; letter-spacing: .03em; text-overflow: ellipsis; text-transform: uppercase; }
|
||||
.party-command-bar kbd { flex: 0 0 auto; padding: 1px 3px; border: 1px solid rgba(230,213,147,.2); border-radius: 3px; background: rgba(0,0,0,.35); color: #dfcf91; font-size: 6px; }
|
||||
|
||||
.hud-minimap {
|
||||
.hud-top-right {
|
||||
position: absolute;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
right: max(12px, env(safe-area-inset-right));
|
||||
display: grid;
|
||||
width: 154px;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: stretch;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
scale: var(--game-ui-scale, 1);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.hud-minimap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -818,6 +834,28 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.hud-minimap__caption strong { font: 600 11px Georgia, serif; }
|
||||
.hud-minimap__caption span { margin-top: 2px; color: var(--muted); font-size: 7px; }
|
||||
|
||||
.player-status-panel { position: relative; display: grid; width: 100%; min-width: 0; gap: 4px; pointer-events: auto; }
|
||||
.player-status-panel__section { padding: 4px; border: 1px solid rgba(164,202,137,.24); border-radius: 5px 2px 7px 2px; background: linear-gradient(105deg, rgba(5,15,11,.92), rgba(12,29,20,.84)); box-shadow: 0 5px 16px rgba(0,0,0,.3); backdrop-filter: blur(5px); }
|
||||
.player-status-panel__section--debuffs { border-color: rgba(219,112,94,.28); background: linear-gradient(105deg, rgba(29,10,10,.91), rgba(22,15,13,.86)); }
|
||||
.player-status-panel__section > header { display: flex; align-items: center; justify-content: space-between; gap: 5px; margin-bottom: 3px; color: #c7d8b8; font-size: 6px; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.player-status-panel__section--debuffs > header { color: #e7b09e; }
|
||||
.player-status-panel__section > header b { display: grid; min-width: 13px; height: 13px; place-items: center; border-radius: 7px; background: rgba(135,176,107,.16); color: #e5efdb; font-size: 6px; }
|
||||
.player-status-panel__section--debuffs > header b { background: rgba(205,94,77,.16); color: #ffd2c2; }
|
||||
.player-status-panel__icons { display: flex; min-height: 22px; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 3px; }
|
||||
.player-status-panel__strip { display: contents; }
|
||||
.player-status-panel__empty { align-self: center; color: rgba(177,192,166,.62); font-size: 6px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.player-status-panel__section--debuffs .player-status-panel__empty { color: rgba(211,166,151,.6); }
|
||||
.player-status-panel__synthetic { flex: 0 0 auto; }
|
||||
.player-status-panel__synthetic .aura__timer:empty { display: none; }
|
||||
.player-status-tooltip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: min(250px, calc(100vw - 24px)); padding: 8px 9px; border: 1px solid rgba(190,218,157,.42); border-radius: 7px 2px 9px 2px; background: linear-gradient(145deg, rgba(8,21,14,.98), rgba(3,10,7,.98)); box-shadow: 0 12px 30px rgba(0,0,0,.58), inset 0 0 18px rgba(122,176,91,.06); color: #e9efe2; text-align: left; pointer-events: none; }
|
||||
.player-status-tooltip--debuff { border-color: rgba(228,122,101,.5); background: linear-gradient(145deg, rgba(35,12,11,.98), rgba(12,7,7,.98)); }
|
||||
.player-status-tooltip > header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.player-status-tooltip > header strong { font: 600 11px Georgia, serif; }
|
||||
.player-status-tooltip > header span { color: #9ece87; font-size: 7px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.player-status-tooltip--debuff > header span { color: #ef9b86; }
|
||||
.player-status-tooltip p { margin: 5px 0 7px; color: #c3cebb; font-size: 8px; line-height: 1.42; }
|
||||
.player-status-tooltip footer { display: flex; flex-wrap: wrap; gap: 3px 7px; padding-top: 5px; border-top: 1px solid rgba(200,220,177,.12); color: #dfcb83; font-size: 7px; text-transform: capitalize; }
|
||||
|
||||
.hud-bottom-stack {
|
||||
position: absolute;
|
||||
bottom: max(22px, calc(env(safe-area-inset-bottom) + 16px));
|
||||
@@ -919,9 +957,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.cast-bar__track { height: 9px; margin-top: 5px; overflow: hidden; border: 1px solid rgba(255,255,255,.12); border-radius: 5px; background: rgba(0,0,0,.7); }
|
||||
.cast-bar__track span { display: block; height: 100%; background: linear-gradient(90deg, #c39a47, #f2dc80); box-shadow: 0 0 9px rgba(240,210,111,.42); transition: width 80ms linear; }
|
||||
.cast-bar--channel .cast-bar__track span { background: linear-gradient(90deg, #7551a9, #bc8be2); }
|
||||
.player-frame, .hud-minimap, .target-frame { scale: var(--game-ui-scale, 1); }
|
||||
.player-frame, .target-frame { scale: var(--game-ui-scale, 1); }
|
||||
.player-frame { transform-origin: top left; }
|
||||
.hud-minimap { transform-origin: top right; }
|
||||
.target-frame { transform-origin: top center; }
|
||||
.action-slot__icon img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
|
||||
|
||||
@@ -1302,7 +1339,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.player-frame__portrait { height: 48px; }
|
||||
.party-frames { top: max(91px, calc(env(safe-area-inset-top) + 79px)); width: 194px; }
|
||||
.party-frame--player { display: none; }
|
||||
.hud-minimap { width: 124px; }
|
||||
.hud-top-right { width: 124px; }
|
||||
.hud-minimap__map { width: 108px; height: 108px; }
|
||||
.spellbook-panel__layout { grid-template-columns: 1fr; }
|
||||
.spellbook-panel__details { display: none; }
|
||||
@@ -1337,9 +1374,11 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-frame { min-height: 34px; grid-template-columns: 25px minmax(0,1fr); padding: 3px 4px; }
|
||||
.party-frame__sigil { width: 23px; height: 23px; }
|
||||
.party-frame__identity small { display: none; }
|
||||
.hud-minimap { right: 7px; width: 94px; }
|
||||
.hud-top-right { right: 7px; width: 94px; }
|
||||
.hud-minimap__map { width: 86px; height: 86px; }
|
||||
.hud-minimap__caption { display: none; }
|
||||
.player-status-panel__section { padding: 3px; }
|
||||
.player-status-tooltip { width: min(220px, 68vw); }
|
||||
.hud-bottom-stack > .action-bar { width: min(360px, calc(100vw - 18px)); }
|
||||
.hud-bottom-stack > .control-hint { max-width: min(360px, calc(100vw - 18px)); }
|
||||
.action-bar { --action-slot-size: 36px; width: calc(100vw - 12px); }
|
||||
@@ -1379,7 +1418,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.player-frame { top: 7px; left: 7px; transform: scale(.88); transform-origin: top left; }
|
||||
.hud-minimap { top: 6px; right: 7px; transform: scale(.86); transform-origin: top right; }
|
||||
.hud-top-right { top: 6px; right: 7px; transform: scale(.86); transform-origin: top right; }
|
||||
.hud-bottom-stack > .action-bar { width: min(360px, calc(100vw - 18px)); }
|
||||
.hud-bottom-stack > .control-hint { max-width: min(440px, calc(100vw - 18px)); }
|
||||
.action-bar { --action-slot-size: 36px; bottom: 19px; width: min(360px, calc(100vw - 18px)); }
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import {
|
||||
LONG_TERM_AURA_DURATION_MS,
|
||||
auraDisplayName,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIcon,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
} from "./AuraStrip";
|
||||
|
||||
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
return {
|
||||
@@ -12,6 +19,22 @@ function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function activeAura(definition: AuraDefinition, remainingAbsorb = 0): ActiveAura {
|
||||
return {
|
||||
instanceId: "test-aura-instance",
|
||||
definition,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
appliedAt: 1_000,
|
||||
expiresAt: definition.durationMs === null ? null : 1_000 + definition.durationMs,
|
||||
stacks: 1,
|
||||
absorbs: remainingAbsorb > 0
|
||||
? [{ id: "test-absorb", remaining: remainingAbsorb }]
|
||||
: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("aura strip visibility", () => {
|
||||
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
|
||||
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
|
||||
@@ -24,4 +47,28 @@ describe("aura strip visibility", () => {
|
||||
);
|
||||
expect(auraDefinitionIcon(aura())).toBeNull();
|
||||
});
|
||||
|
||||
it("removes internal DBC and aura identifiers from player-facing names", () => {
|
||||
expect(auraDisplayName(aura({ name: "Power Word: Fortitude (DBC 1243, Aura 29)" }))).toBe(
|
||||
"Power Word: Fortitude",
|
||||
);
|
||||
expect(auraDisplayName(aura({ name: "Arcane Intellect (Aura 13)" }))).toBe(
|
||||
"Arcane Intellect",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies permanent and one-minute effects as long-term without using remaining time", () => {
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: null }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers authored tooltip copy and explains absorb-only fallback auras", () => {
|
||||
expect(auraDescription(activeAura(aura({ description: "Raises armor by 10%." })))).toBe(
|
||||
"Raises armor by 10%.",
|
||||
);
|
||||
expect(auraDescription(activeAura(aura({
|
||||
absorbs: [{ id: "test-absorb", amount: 250 }],
|
||||
}), 175))).toBe("Absorbs up to 175 incoming damage.");
|
||||
});
|
||||
});
|
||||
+75
-6
@@ -1,12 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
import type { ActiveAura, AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
||||
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
|
||||
|
||||
export const LONG_TERM_AURA_DURATION_MS = 60_000;
|
||||
|
||||
interface AuraStripProps {
|
||||
readonly targetId: string;
|
||||
readonly targetIds?: readonly string[];
|
||||
readonly dispositions?: readonly AuraDisposition[];
|
||||
readonly className?: string;
|
||||
readonly hideLongTerm?: boolean;
|
||||
readonly showOnlyLongTerm?: boolean;
|
||||
readonly targetLabels?: Readonly<Record<string, string>>;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}
|
||||
|
||||
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
|
||||
@@ -17,17 +26,55 @@ export function auraDefinitionIcon(definition: AuraDefinition): string | null {
|
||||
return definition.icon?.trim() || null;
|
||||
}
|
||||
|
||||
export function auraDisplayName(definition: AuraDefinition): string {
|
||||
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
|
||||
}
|
||||
|
||||
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
|
||||
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
|
||||
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
|
||||
}
|
||||
|
||||
export function auraDescription(aura: ActiveAura): string {
|
||||
const authored = aura.definition.description?.trim();
|
||||
if (authored) return authored;
|
||||
|
||||
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
|
||||
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
|
||||
if (aura.definition.controlImmunities?.length) {
|
||||
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
|
||||
}
|
||||
if (aura.definition.modifiers?.length) {
|
||||
return aura.definition.disposition === "buff"
|
||||
? "Improves combat attributes while active."
|
||||
: "Reduces combat effectiveness while active.";
|
||||
}
|
||||
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
|
||||
return aura.definition.disposition === "buff"
|
||||
? "A beneficial effect is active."
|
||||
: "A harmful effect is active.";
|
||||
}
|
||||
|
||||
export function AuraStrip({
|
||||
targetId,
|
||||
targetIds,
|
||||
dispositions = ["buff", "debuff"],
|
||||
className = "",
|
||||
hideLongTerm = false,
|
||||
showOnlyLongTerm = false,
|
||||
targetLabels,
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: AuraStripProps) {
|
||||
const allAuras = useCombatStore((state) => state.auras);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const auras = allAuras.filter((aura) => (
|
||||
aura.targetId === targetId
|
||||
requestedTargetIds.includes(aura.targetId)
|
||||
&& dispositions.includes(aura.definition.disposition)
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
));
|
||||
|
||||
@@ -44,14 +91,36 @@ export function AuraStrip({
|
||||
{auras.map((aura) => {
|
||||
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
||||
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
||||
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
||||
const displayName = auraDisplayName(aura.definition);
|
||||
const targetLabel = targetLabels?.[aura.targetId];
|
||||
const initial = displayName.charAt(0).toUpperCase() || "*";
|
||||
const icon = auraDefinitionIcon(aura.definition);
|
||||
const inspection: StatusEffectInspection = {
|
||||
id: aura.instanceId,
|
||||
name: displayName,
|
||||
disposition: aura.definition.disposition,
|
||||
description: auraDescription(aura),
|
||||
remainingLabel: timer ? `${timer} remaining` : "Permanent",
|
||||
stacks: aura.stacks,
|
||||
...(targetLabel ? { targetLabel } : {}),
|
||||
...(aura.definition.dispelCategory
|
||||
? { dispelCategory: aura.definition.dispelCategory }
|
||||
: {}),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={aura.instanceId}
|
||||
className={`aura aura--${aura.definition.disposition}`}
|
||||
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
|
||||
tabIndex={onInspect ? 0 : undefined}
|
||||
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
aria-describedby={onInspect ? tooltipId : undefined}
|
||||
title={onInspect ? undefined : `${displayName}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onPointerLeave={onInspect ? (event) => {
|
||||
if (event.currentTarget !== document.activeElement) onInspect(null);
|
||||
} : undefined}
|
||||
onFocus={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onBlur={onInspect ? () => onInspect(null) : undefined}
|
||||
>
|
||||
{icon
|
||||
? <img className="aura__icon" src={icon} alt="" draggable={false} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { normalizePreviewFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
const CharacterPreview3D = lazy(() => import("./CharacterPreview3D"));
|
||||
|
||||
@@ -33,6 +34,9 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
const characterClass = classById(source.classId);
|
||||
const name = "name" in source && source.name ? source.name : "New adventurer";
|
||||
const rotation = "rotation" in source ? source.rotation : -8;
|
||||
const previewFrameRate = normalizePreviewFrameRateLimit(
|
||||
"settings" in source ? source.settings?.previewFrameRate : undefined,
|
||||
);
|
||||
const style = {
|
||||
"--race-accent": race.accent,
|
||||
"--class-color": characterClass.color,
|
||||
@@ -45,7 +49,7 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />
|
||||
) : (
|
||||
<Suspense fallback={<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />}>
|
||||
<CharacterPreview3D source={source} />
|
||||
<CharacterPreview3D source={source} frameRateLimit={previewFrameRate} />
|
||||
</Suspense>
|
||||
)}
|
||||
<span className="sr-only">{source.gender} {race.name} {characterClass.name} preview</span>
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import {
|
||||
numericFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "../scene/CappedFrameLoop";
|
||||
|
||||
function FittedCharacter({
|
||||
characterClassColor,
|
||||
@@ -30,19 +36,34 @@ function FittedCharacter({
|
||||
);
|
||||
}
|
||||
|
||||
export default function CharacterPreview3D({ source }: {
|
||||
export default function CharacterPreview3D({ frameRateLimit, source }: {
|
||||
frameRateLimit: PreviewFrameRateLimit;
|
||||
source: CharacterProfile | CharacterDraft;
|
||||
}) {
|
||||
const race = raceById(source.raceId);
|
||||
const characterClass = classById(source.classId);
|
||||
const turnDegrees = "rotation" in source ? source.rotation : -8;
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const targetFps = numericFrameRateLimit(frameRateLimit);
|
||||
const frameloop = !documentVisible
|
||||
? "demand"
|
||||
: targetFps === null ? "always" : "never";
|
||||
return (
|
||||
<div className="character-preview__viewport" aria-hidden="true">
|
||||
<div
|
||||
className="character-preview__viewport"
|
||||
aria-hidden="true"
|
||||
data-frame-rate-limit={frameRateLimit}
|
||||
data-frame-loop={frameloop}
|
||||
>
|
||||
<Canvas
|
||||
dpr={[1, 1.25]}
|
||||
frameloop={frameloop}
|
||||
camera={{ fov: 32, near: 0.05, far: 40, position: [3.2, 1.8, 4.6] }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "default" }}
|
||||
>
|
||||
{targetFps !== null && frameloop === "never" && (
|
||||
<CappedFrameLoop active={documentVisible} targetFps={targetFps} />
|
||||
)}
|
||||
<ambientLight intensity={1.15} color="#d8e2ca" />
|
||||
<hemisphereLight args={["#d8e9ce", "#142019", 1.8]} />
|
||||
<directionalLight position={[3, 5, 4]} intensity={2.1} color="#f0e5bf" />
|
||||
|
||||
+12
-8
@@ -17,6 +17,7 @@ import { useGameStore } from "../game/store";
|
||||
import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { ExperienceBar } from "./ExperienceBar";
|
||||
import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { PlayerFrame } from "./PlayerFrame";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
@@ -344,14 +345,17 @@ export function Hud() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<div className="hud-top-right">
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<PlayerStatusPanel now={clock} />
|
||||
</div>
|
||||
|
||||
{target && settings.showTargetFrame && (
|
||||
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
||||
|
||||
+37
-1
@@ -11,6 +11,14 @@ import {
|
||||
type CombatPresentationSettings,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
|
||||
export interface OptionsPanelSettings {
|
||||
showMobHealthBars: boolean;
|
||||
@@ -24,6 +32,8 @@ export interface OptionsPanelSettings {
|
||||
masterVolume: number;
|
||||
uiScale: number;
|
||||
minimapRotation: "north-up" | "player-up";
|
||||
gameplayFrameRate: GameplayFrameRateLimit;
|
||||
previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps {
|
||||
@@ -87,7 +97,15 @@ function OptionsDialog({
|
||||
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
|
||||
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
|
||||
};
|
||||
const cycleGameplayFrameRate = () => onChange({
|
||||
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const cyclePreviewFrameRate = () => onChange({
|
||||
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
|
||||
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
|
||||
...TOGGLES.map((option) => ({
|
||||
id: `option-${option.id}`,
|
||||
run: () => onChange({ [option.id]: !settings[option.id] }),
|
||||
@@ -141,6 +159,24 @@ function OptionsDialog({
|
||||
</header>
|
||||
|
||||
<div className="options-panel__list">
|
||||
<h3 className="options-panel__section-title">Performance</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>Gameplay frame rate</strong><small>Limit 3D gameplay rendering without changing combat or physics timing.</small></span>
|
||||
{optionButton(
|
||||
"option-gameplay-frame-rate",
|
||||
frameRateLimitLabel(settings.gameplayFrameRate),
|
||||
cycleGameplayFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Character preview frame rate</strong><small>Limit animated character previews shown in menus.</small></span>
|
||||
{optionButton(
|
||||
"option-preview-frame-rate",
|
||||
frameRateLimitLabel(settings.previewFrameRate),
|
||||
cyclePreviewFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Gameplay and interface</h3>
|
||||
{TOGGLES.map((option) => (
|
||||
<div key={option.id} className="option-row">
|
||||
<span><strong>{option.label}</strong><small>{option.description}</small></span>
|
||||
@@ -185,7 +221,7 @@ function OptionsDialog({
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Combat presentation</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>VFX quality</strong><small>High uses the full particle budget; Low reduces particles and lights; Off skips combat VFX.</small></span>
|
||||
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
|
||||
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
|
||||
</div>
|
||||
{COMBAT_ACTOR_GROUPS.map((group) => (
|
||||
|
||||
@@ -149,7 +149,7 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
<i style={{ width: `${resourcePercent}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -273,8 +273,12 @@ export function PartyFrames() {
|
||||
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
|
||||
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={[null, PLAYER_AGGRO_ID]}
|
||||
className="timed-effect-strip--party"
|
||||
/>
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
{onlineMembers.map((member) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
|
||||
function activeAura(
|
||||
targetId: string,
|
||||
durationMs: number | null,
|
||||
expiresAt: number | null = durationMs === null ? null : 10_000 + durationMs,
|
||||
hideFromAuraStrip = false,
|
||||
): ActiveAura {
|
||||
const definition: AuraDefinition = {
|
||||
id: `aura-${targetId}`,
|
||||
name: "Test Aura",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
...(hideFromAuraStrip ? { hideFromAuraStrip: true } : {}),
|
||||
};
|
||||
return {
|
||||
instanceId: `instance-${targetId}`,
|
||||
definition,
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId,
|
||||
appliedAt: 10_000,
|
||||
expiresAt,
|
||||
stacks: 1,
|
||||
absorbs: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("top-right status visibility", () => {
|
||||
const now = 20_000;
|
||||
const partyTargets = new Set(["party-1"]);
|
||||
|
||||
it("shows every player aura and centralizes long-term party auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now, partyTargets)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import {
|
||||
AuraStrip,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
auraDisplayName,
|
||||
} from "./AuraStrip";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
type StatusEffectInspection,
|
||||
} from "./statusEffectInspection";
|
||||
|
||||
const PLAYER_EFFECT_TARGETS: readonly (string | null)[] = [null, PLAYER_AGGRO_ID];
|
||||
const TOOLTIP_ID = "player-status-effect-tooltip";
|
||||
|
||||
function statusLabel(disposition: StatusEffectInspection["disposition"]): string {
|
||||
return disposition === "buff" ? "Buff" : "Debuff";
|
||||
}
|
||||
|
||||
export function auraDisplaysInPlayerStatusPanel(
|
||||
aura: ActiveAura,
|
||||
now: number,
|
||||
partyTargetIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const belongsInPanel = aura.targetId === PLAYER_AGGRO_ID
|
||||
|| (partyTargetIds.has(aura.targetId) && auraDefinitionIsLongTerm(aura.definition));
|
||||
return belongsInPanel
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now);
|
||||
}
|
||||
|
||||
export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
const auras = useCombatStore((state) => state.auras);
|
||||
const effects = useCombatStore((state) => state.effects);
|
||||
const shield = useCombatStore((state) => state.shield);
|
||||
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
|
||||
|
||||
const partyAuraTargetIds = partyMembers.map((member) => member.id);
|
||||
const partyAuraTargetIdSet = new Set(partyAuraTargetIds);
|
||||
const partyAuraTargetLabels = Object.fromEntries(
|
||||
partyMembers.map((member) => [member.id, member.name]),
|
||||
);
|
||||
const activeAuras = auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
|
||||
));
|
||||
const activeEffects = effects.filter((effect) => (
|
||||
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
|
||||
&& timedEffectRemainingMs(effect, now) > 0
|
||||
));
|
||||
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
|
||||
const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length
|
||||
+ activeEffects.filter((effect) => effect.kind === "hot").length
|
||||
+ (shieldActive ? 1 : 0);
|
||||
const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length
|
||||
+ activeEffects.filter((effect) => effect.kind === "dot").length;
|
||||
const activeInspectionIds = new Set([
|
||||
...activeAuras.map((aura) => aura.instanceId),
|
||||
...activeEffects.map((effect) => effect.id),
|
||||
...(shieldActive ? ["player-absorb-shield"] : []),
|
||||
]);
|
||||
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
|
||||
const shieldInspection: StatusEffectInspection = {
|
||||
id: "player-absorb-shield",
|
||||
name: "Absorb Shield",
|
||||
disposition: "buff",
|
||||
description: `Absorbs up to ${Math.ceil(shield)} incoming damage before health is lost.`,
|
||||
remainingLabel: shieldExpiresAt > now
|
||||
? `${formatTimedEffectTimer(shieldExpiresAt - now)} remaining`
|
||||
: "Until depleted",
|
||||
stacks: 1,
|
||||
};
|
||||
const liveInspection = inspected && inspectedIsActive
|
||||
? (() => {
|
||||
if (inspected.id === shieldInspection.id) return shieldInspection;
|
||||
const aura = activeAuras.find((candidate) => candidate.instanceId === inspected.id);
|
||||
if (aura) {
|
||||
const remaining = aura.expiresAt === null
|
||||
? "Permanent"
|
||||
: `${formatTimedEffectTimer(Math.max(0, aura.expiresAt - now))} remaining`;
|
||||
return {
|
||||
...inspected,
|
||||
name: auraDisplayName(aura.definition),
|
||||
disposition: aura.definition.disposition,
|
||||
description: auraDescription(aura),
|
||||
remainingLabel: remaining,
|
||||
stacks: aura.stacks,
|
||||
...(aura.definition.dispelCategory
|
||||
? { dispelCategory: aura.definition.dispelCategory }
|
||||
: {}),
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
const effect = activeEffects.find((candidate) => candidate.id === inspected.id);
|
||||
if (effect) {
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
return {
|
||||
...inspected,
|
||||
name: ability?.name ?? effect.sourceAbilityId,
|
||||
disposition: effect.kind === "hot" ? "buff" : "debuff",
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
return inspected;
|
||||
})()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (inspected && !inspectedIsActive) setInspected(null);
|
||||
}, [inspected, inspectedIsActive]);
|
||||
|
||||
return (
|
||||
<section className="player-status-panel" aria-label="Active buffs and debuffs">
|
||||
<div
|
||||
className="player-status-panel__section player-status-panel__section--buffs"
|
||||
aria-label={`${buffCount} active ${buffCount === 1 ? "buff" : "buffs"}`}
|
||||
>
|
||||
<header><strong>Buffs</strong><b>{buffCount}</b></header>
|
||||
<div className="player-status-panel__icons">
|
||||
{shieldActive ? (
|
||||
<span
|
||||
className="aura aura--buff aura--inspectable player-status-panel__synthetic"
|
||||
tabIndex={0}
|
||||
aria-label={`Absorb Shield, buff, ${shieldInspection.remainingLabel}`}
|
||||
aria-describedby={TOOLTIP_ID}
|
||||
onPointerEnter={() => setInspected(shieldInspection)}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.currentTarget !== document.activeElement) setInspected(null);
|
||||
}}
|
||||
onFocus={() => setInspected(shieldInspection)}
|
||||
onBlur={() => setInspected(null)}
|
||||
>
|
||||
<span className="aura__initial" aria-hidden="true">S</span>
|
||||
<b className="aura__timer" aria-hidden="true">
|
||||
{shieldExpiresAt > now ? formatTimedEffectTimer(shieldExpiresAt - now) : ""}
|
||||
</b>
|
||||
</span>
|
||||
) : null}
|
||||
<AuraStrip
|
||||
targetId={PLAYER_AGGRO_ID}
|
||||
dispositions={["buff"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["buff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
kinds={["hot"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{!buffCount ? <span className="player-status-panel__empty">None active</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="player-status-panel__section player-status-panel__section--debuffs"
|
||||
aria-label={`${debuffCount} active ${debuffCount === 1 ? "debuff" : "debuffs"}`}
|
||||
>
|
||||
<header><strong>Debuffs</strong><b>{debuffCount}</b></header>
|
||||
<div className="player-status-panel__icons">
|
||||
<AuraStrip
|
||||
targetId={PLAYER_AGGRO_ID}
|
||||
dispositions={["debuff"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["debuff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
kinds={["dot"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{!debuffCount ? <span className="player-status-panel__empty">None active</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveInspection ? (
|
||||
<div
|
||||
id={TOOLTIP_ID}
|
||||
className={`player-status-tooltip player-status-tooltip--${liveInspection.disposition}`}
|
||||
role="tooltip"
|
||||
>
|
||||
<header>
|
||||
<strong>{liveInspection.name}</strong>
|
||||
<span>{statusLabel(liveInspection.disposition)}</span>
|
||||
</header>
|
||||
<p>{liveInspection.description}</p>
|
||||
<footer>
|
||||
<span>{liveInspection.remainingLabel}</span>
|
||||
{liveInspection.stacks > 1 ? <span>{liveInspection.stacks} stacks</span> : null}
|
||||
{liveInspection.dispelCategory ? <span>{liveInspection.dispelCategory}</span> : null}
|
||||
{liveInspection.targetLabel ? <span>Target: {liveInspection.targetLabel}</span> : null}
|
||||
</footer>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { timedEffectDescription } from "./statusEffectInspection";
|
||||
|
||||
describe("timed effect tooltip copy", () => {
|
||||
it("uses the source ability description when one is available", () => {
|
||||
expect(timedEffectDescription("hot", " Restores health over 12 sec. ")).toBe(
|
||||
"Restores health over 12 sec.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes periodic healing and damage when source copy is unavailable", () => {
|
||||
expect(timedEffectDescription("hot")).toContain("Restores health");
|
||||
expect(timedEffectDescription("dot")).toContain("Deals damage");
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,33 @@ import {
|
||||
formatTimedEffectTimer,
|
||||
timedEffectRemainingMs,
|
||||
} from "../game/timedEffectDisplay";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
type StatusEffectInspectHandler,
|
||||
type StatusEffectInspection,
|
||||
} from "./statusEffectInspection";
|
||||
|
||||
export type TimedEffectKind = TimedCombatEffect["kind"];
|
||||
|
||||
interface TimedEffectStripProps {
|
||||
readonly targetId: string | null;
|
||||
readonly targetIds?: readonly (string | null)[];
|
||||
readonly kinds?: readonly TimedEffectKind[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}
|
||||
|
||||
function LiveTimedEffectStrip({
|
||||
effects,
|
||||
className = "",
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: {
|
||||
readonly effects: readonly TimedCombatEffect[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
@@ -45,12 +57,29 @@ function LiveTimedEffectStrip({
|
||||
{visibleEffects.map(({ effect, ability, remainingMs }) => {
|
||||
const label = ability?.name ?? effect.sourceAbilityId;
|
||||
const timer = formatTimedEffectTimer(remainingMs);
|
||||
const disposition = effect.kind === "hot" ? "buff" : "debuff";
|
||||
const inspection: StatusEffectInspection = {
|
||||
id: effect.id,
|
||||
name: label,
|
||||
disposition,
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${timer} remaining`,
|
||||
stacks: 1,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={effect.id}
|
||||
className={`timed-effect timed-effect--${effect.kind}`}
|
||||
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
|
||||
tabIndex={onInspect ? 0 : undefined}
|
||||
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time, ${timer} remaining`}
|
||||
title={`${label} · ${timer} remaining`}
|
||||
aria-describedby={onInspect ? tooltipId : undefined}
|
||||
title={onInspect ? undefined : `${label} · ${timer} remaining`}
|
||||
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onPointerLeave={onInspect ? (event) => {
|
||||
if (event.currentTarget !== document.activeElement) onInspect(null);
|
||||
} : undefined}
|
||||
onFocus={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onBlur={onInspect ? () => onInspect(null) : undefined}
|
||||
>
|
||||
{ability?.icon
|
||||
? <img src={ability.icon} alt="" draggable={false} />
|
||||
@@ -69,14 +98,25 @@ function LiveTimedEffectStrip({
|
||||
*/
|
||||
export function TimedEffectStrip({
|
||||
targetId,
|
||||
targetIds,
|
||||
kinds = ["dot", "hot"],
|
||||
className,
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: TimedEffectStripProps) {
|
||||
const allEffects = useCombatStore((state) => state.effects);
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const effects = allEffects.filter((effect) => (
|
||||
effect.targetId === targetId && kinds.includes(effect.kind)
|
||||
requestedTargetIds.includes(effect.targetId) && kinds.includes(effect.kind)
|
||||
));
|
||||
|
||||
if (!effects.length) return null;
|
||||
return <LiveTimedEffectStrip effects={effects} className={className} />;
|
||||
return (
|
||||
<LiveTimedEffectStrip
|
||||
effects={effects}
|
||||
className={className}
|
||||
onInspect={onInspect}
|
||||
tooltipId={tooltipId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuraDisposition, DispelCategory } from "../game/combatAuras";
|
||||
|
||||
export interface StatusEffectInspection {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
readonly description: string;
|
||||
readonly remainingLabel: string;
|
||||
readonly stacks: number;
|
||||
readonly targetLabel?: string;
|
||||
readonly dispelCategory?: DispelCategory;
|
||||
}
|
||||
|
||||
export type StatusEffectInspectHandler = (effect: StatusEffectInspection | null) => void;
|
||||
|
||||
export function timedEffectDescription(
|
||||
kind: "dot" | "hot",
|
||||
authoredDescription?: string,
|
||||
): string {
|
||||
const authored = authoredDescription?.trim();
|
||||
if (authored) return authored;
|
||||
return kind === "hot"
|
||||
? "Restores health periodically while active."
|
||||
: "Deals damage periodically while active.";
|
||||
}
|
||||
Reference in new issue
Block a user