Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+25 -11
View File
@@ -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}
/>
</>
);
+111
View File
@@ -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();
});
});
+63
View File
@@ -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}>");
+1 -1
View File
@@ -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;
+21 -1
View File
@@ -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),
+22 -3
View File
@@ -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",
}],
},
});
+136 -73
View File
@@ -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);
}
}
});
});
+127 -3
View File
@@ -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
View File
@@ -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}
+33
View File
@@ -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
View File
@@ -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",
]);
});
});
+407
View File
@@ -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;
}
+220
View File
@@ -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 };
}
}
+370
View File
@@ -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 })));
}
}
+170
View File
@@ -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);
});
});
+473
View File
@@ -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);
});
+435
View File
@@ -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);
}
});
});
+215
View File
@@ -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;
}
+156
View File
@@ -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[];
}
+30
View File
@@ -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]);
+20
View File
@@ -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);
}
+70
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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;
+39
View File
@@ -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();
}
});
});
+21
View File
@@ -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;
};
}
+21
View File
@@ -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))));
}
+68
View File
@@ -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();
}
});
});
+15
View File
@@ -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);
}
+68
View File
@@ -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
View File
@@ -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;
+33
View File
@@ -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();
});
});
+38
View File
@@ -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;
}
+83 -1
View File
@@ -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
View File
@@ -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;
};
+28
View File
@@ -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;
}
+11 -1
View File
@@ -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]);
});
});
+8 -2
View File
@@ -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))}
/>
);
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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]} />
+11
View File
@@ -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];
}
+65
View File
@@ -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]);
});
});
+48
View File
@@ -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);
};
}
+123
View File
@@ -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);
});
});
+137
View File
@@ -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;
}
+64
View File
@@ -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);
});
});
+86
View File
@@ -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 };
}
+9 -4
View File
@@ -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");
});
});
+6 -1
View File
@@ -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
View File
@@ -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)); }
+49 -2
View File
@@ -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
View File
@@ -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} />
+5 -1
View File
@@ -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>
+24 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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) => (
+7 -3
View File
@@ -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) => {
+48
View File
@@ -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);
});
});
+237
View File
@@ -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>
);
}
+15
View File
@@ -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");
});
});
+44 -4
View File
@@ -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}
/>
);
}
+25
View File
@@ -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.";
}