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

+52
View File
@@ -0,0 +1,52 @@
# Balance benchmarks
The balance benchmark suite provides repeatable evidence for tuning World of Warcraft, CoA, and Runes of Magic classes. It runs fixed builds through versioned damage, tanking, healing, movement, stacking, and resource-endurance scenarios. The goal is to reveal balance changes and outliers, not to name a universally best class.
## Default cohort
The default comparison cohort is level 60, matching CoA's authored cap while keeping the three content sources in one cohort. It uses the `coefficient-empty` preset: no equipment, so the first cohort isolates base kits, scaling coefficients, talents, and resources. Gear-normalized cohorts should be added separately rather than mixed with this one.
Every comparison must use the same gear preset, scenario version, baseline policy ID, duration scale, and seed set. A changed level, gear preset, scenario, policy, or app version creates a new cohort and should not be compared directly with an older one.
Run the suite with:
```sh
npm run balance:benchmark
```
Use `npm run balance:smoke` for a short end-to-end check. The full command accepts comma-separated `--class`, `--role`, `--scenario`, and `--seeds` filters, plus `--level`, `--duration-scale`, and `--output`. For example:
```sh
npm run balance:benchmark -- --class priest,rom-priest --role healer --seeds 1,2,3
```
The command is the intended stable entry point for the benchmark runner. Its output directory contains:
- `matrix.json`: raw run reports, build and scenario definitions, seed metadata, validation results, ability details, resources, and event counts.
- `matrix.csv`: one row per scenario, build, and metric, with observed/null counts plus median, p10, and p90 values.
- `summary.md`: a concise role-separated comparison for review and pull-request evidence.
Keep the JSON artifact when investigating a result. The CSV and Markdown are derived views and can be regenerated from it.
## Reading results
The reported center is the median across validated seeded runs. The p10–p90 interval shows run-to-run spread; a wide interval calls for more seeds or investigation before tuning. Invalid runs are counted but excluded from distributions. An early death or other scenario stop can be a valid outcome and should not be discarded merely because the full duration was not reached.
Interpret each role as a vector of outcomes, not a single ranking:
- Damage builds emphasize DPS and priority-target damage, with deaths and mechanics failures alongside throughput.
- Tanks emphasize damage taken per second, mitigation, avoidance, threat control, minimum health, survival, and their damage contribution.
- Healers emphasize healing per second, consumed absorbs, overheal, party health, deaths, and time to resource failure.
- Movement and stack scenarios add movement interrupts and stack compliance to the relevant role vector.
No composite score is produced. Compare a build only with other builds in the same scenario version and cohort, and inspect the underlying metrics before making a balance change. A null time to resource failure means the resource was not exhausted during that run; it is not zero. Similarly, a null time to first death means no death occurred.
## Limitations
- The baseline policy is a deterministic, maintainable reference policy, not an optimal player rotation. Skilled play may outperform it, and policy changes can move results without a class-data change.
- The initial `coefficient-empty` cohort does not model production gear breakpoints or itemization. Add explicit equal-budget gear presets for that work.
- Runes of Magic coverage is primary-class-only initially. Secondary-class and dual-class interactions require their own explicitly versioned cohort when supported.
- Results cover mechanics represented by the simulator and scenarios; they do not replace live encounter playtesting, usability review, or PvP testing.
- Small seed sets can miss rare random outcomes. Treat p10 and p90 as descriptive bounds for the sampled runs, not guarantees.
- Total metrics depend on scenario duration. Compare rates or fixed-duration totals only within the same scenario definition.
- Gear-stat budgets must be equivalent, but equivalent budgets do not guarantee identical interactions with every class kit.
+69
View File
@@ -1451,3 +1451,72 @@ Vite's existing large-chunk advisory remains informational.
passed all 755 cases across 132 files (including the 28 avatar cases), and the passed all 755 cases across 132 files (including the 28 avatar cases), and the
TypeScript production build completed successfully with 983 modules TypeScript production build completed successfully with 983 modules
transformed. The existing large-chunk advisory remains informational. transformed. The existing large-chunk advisory remains informational.
# 2026-08-28 — configurable render caps and idle-loop efficiency
- Opened the saved Human Priest roster after a fresh client reload. The full
body and starter staff rendered correctly in the single character-preview
canvas, which reported the persisted 30 FPS preview cap and manual frame loop.
- Entered Wailing Caverns as healer and exercised the gameplay setting live.
The performance probe settled at about 30.5 FPS for the 30 FPS option and 60
FPS for the 60 FPS option; combat and fixed-step physics continued normally.
- Pausing switched the dungeon canvas to demand rendering. Its performance
counters remained byte-for-byte unchanged over 1.4 seconds, confirming that
the local paused scene did not keep rendering in the background.
- Returned gameplay rendering to the default 60 FPS and left the character
preview at 30 FPS. Evidence reviewed during the browser run included the
roster-preview and live dungeon screenshots. The final fresh session emitted
no errors; the existing third-party initialization deprecation warning
remained non-blocking.
- Verification: the focused efficiency tests passed all 83 cases, all 28 avatar
tests passed, the complete Vitest suite passed all 809 cases across 148 files,
and the production build completed with 1,043 modules transformed. KTX2 and
environment-performance audits remained green; the existing large-chunk
advisory remains informational.
# 2026-08-30 — authored weapon grips and ready stances
- Reloaded the offline client and switched a Human female preview directly
between Hunter, Warrior, and Priest. The bow, sword/shield, and staff changed
immediately to their native HoldBow, Ready1H, and Ready2HL poses; no stale
class pose or backward procedural axis remained.
- Verified RuneWaker Human Warrior and Scout previews against their explicit
hand/shield locator frames. The one-hand, shield, and bow silhouettes used the
RuneWaker socket-axis adapter and remained proportionate to the character.
Also checked the Ascension Human Ranger fallback with its bow visible in a
supported ready pose.
- The saved Human Priest roster preview showed the complete body and staff with
both hands aligned to the shaft. In Wailing Caverns, the player and party
equipment remained attached through live combat animation without a second
local weapon swing or load-time placement jump.
- Evidence: `playtest-artifacts/2026-08-30-weapon-grip/weapon-grip-roster.png`,
`human-hunter-holdbow.png`, `human-warrior-ready1h.png`,
`human-priest-ready2hl.png`, `runewaker-warrior-sockets.png`,
`runewaker-scout-bow.png`, `ascension-ranger-bow.png`, and
`weapon-grip-dungeon.png`.
- Verification: all 41 avatar tests passed, the 37-test focused weapon/stance
suite passed, and the production build completed with 1,047 modules
transformed. Asset audits remained green with zero KTX2 fallbacks and zero
environment-performance regressions; the existing large-chunk advisory
remains informational.
# 2026-08-30 — bounded combat VFX bursts and silent audio default
- Created a fresh offline Human Priest and confirmed the in-game Options panel
opened with **Master volume** at 0%. Gameplay and preview frame-rate controls,
VFX quality, and per-actor VFX/audio controls remained available.
- Entered Wailing Caverns as healer, sent the party into combat, and captured a
live four-effect spell burst. High quality reported four active effect systems
and zero combat point lights; additive particles, impacts, melee arcs, the HUD,
and combat telegraphs remained visible.
- The captured burst reported 60 FPS, a 16.7 ms average frame, a 16.8 ms slowest
frame, 258 draw calls, and 353,526 triangles at 1.5 pixel ratio. A three-second
combat sample reached five concurrent effects, stayed between 54.1 and 60 FPS,
and returned to a 16.7 ms frame average.
- A cold Vite dynamic import failed once while first opening character creation;
reloading after compilation resolved it. The final dungeon run had no further
errors; the existing third-party initialization deprecation warning remained.
- Verification: the complete Vitest suite passed all 839 cases across 153 files,
the production build completed with 1,047 modules transformed, KTX2 and
environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.8", "version": "0.1.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -93,6 +93,8 @@
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all", "assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs", "assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs", "assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit", "prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build", "build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android", "build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.8", "versionName": "0.1.9",
"versionCode": 1008 "versionCode": 1009
} }
+174
View File
@@ -0,0 +1,174 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { CLASSES, type ClassId } from "../../src/app/characterCatalog";
import {
CANONICAL_BENCHMARK_LEVEL,
loadCanonicalBenchmarkBuilds,
} from "../../src/game/benchmark/benchmarkBuilds";
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
import {
renderBenchmarkMatrixMarkdown,
serializeBenchmarkMatrixCsv,
} from "../../src/game/benchmark/benchmarkReport";
import { runCombatBenchmark } from "../../src/game/benchmark/benchmarkRunner";
import { BENCHMARK_SCENARIOS } from "../../src/game/benchmark/benchmarkScenarios";
import type { BenchmarkMatrixReport } from "../../src/game/benchmark/benchmarkTypes";
import type { PartyRole } from "../../src/game/partyRoles";
interface CliOptions {
readonly level: number;
readonly classIds: readonly ClassId[] | null;
readonly roles: readonly PartyRole[] | null;
readonly scenarioIds: readonly string[] | null;
readonly seeds: readonly number[];
readonly durationScale: number;
readonly output: string | null;
}
const projectRoot = resolve(import.meta.dirname, "../..");
function argumentValue(arguments_: readonly string[], name: string): string | null {
const index = arguments_.indexOf(name);
if (index < 0) return null;
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
return value;
}
function list(value: string | null): readonly string[] | null {
if (value === null) return null;
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
if (!values.length) throw new Error("A comma-separated filter cannot be empty.");
return values;
}
function finiteNumber(value: string | null, fallback: number, name: string): number {
if (value === null) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number.`);
return parsed;
}
function parseOptions(arguments_: readonly string[]): CliOptions {
const knownClasses = new Set(CLASSES.map((definition) => definition.id));
const classIds = list(argumentValue(arguments_, "--class"));
if (classIds?.some((id) => !knownClasses.has(id as ClassId))) {
throw new Error(`Unknown class filter: ${classIds.find((id) => !knownClasses.has(id as ClassId))}.`);
}
const roles = list(argumentValue(arguments_, "--role"));
if (roles?.some((role) => !["tank", "healer", "damage"].includes(role))) {
throw new Error(`Unknown role filter: ${roles.find((role) => !["tank", "healer", "damage"].includes(role))}.`);
}
const scenarioIds = list(argumentValue(arguments_, "--scenario"));
const knownScenarios = new Set(BENCHMARK_SCENARIOS.map((scenario) => scenario.id));
if (scenarioIds?.some((id) => !knownScenarios.has(id))) {
throw new Error(`Unknown scenario filter: ${scenarioIds.find((id) => !knownScenarios.has(id))}.`);
}
const seeds = (list(argumentValue(arguments_, "--seeds")) ?? ["1", "2", "3"])
.map((seed) => Number(seed));
if (!seeds.length || seeds.some((seed) => !Number.isSafeInteger(seed))) {
throw new Error("--seeds must contain comma-separated safe integers.");
}
const durationScale = finiteNumber(argumentValue(arguments_, "--duration-scale"), 1, "--duration-scale");
if (durationScale <= 0) throw new Error("--duration-scale must be greater than zero.");
return {
level: Math.trunc(finiteNumber(argumentValue(arguments_, "--level"), CANONICAL_BENCHMARK_LEVEL, "--level")),
classIds: classIds as readonly ClassId[] | null,
roles: roles as readonly PartyRole[] | null,
scenarioIds,
seeds,
durationScale,
output: argumentValue(arguments_, "--output"),
};
}
function help(): string {
return [
"Healer Man balance benchmark",
"",
"Usage: npm run balance:benchmark -- [options]",
"",
"Options:",
" --class mage,templar Limit primary classes.",
" --role damage,healer Limit roles.",
" --scenario id,id Limit scenario IDs.",
" --level 60 Comparison level (default 60).",
" --seeds 1,2,3 Deterministic seeds.",
" --duration-scale 1 Scale scenario duration (smoke tests only below 1).",
" --output path Output directory under the current path or absolute.",
" --help Show this help.",
"",
].join("\n");
}
function timestampDirectory(iso: string): string {
return iso.replaceAll(":", "-").replace(/\.\d{3}Z$/u, "Z");
}
async function main(): Promise<void> {
const arguments_ = process.argv.slice(2);
if (arguments_.includes("--help")) {
process.stdout.write(help());
return;
}
const options = parseOptions(arguments_);
const generatedAt = new Date().toISOString();
const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { version: string };
const builds = (await loadCanonicalBenchmarkBuilds(options.level, options.classIds ?? undefined))
.filter((build) => !options.roles || options.roles.includes(build.role));
const scenarios = BENCHMARK_SCENARIOS.filter((scenario) => (
!options.scenarioIds || options.scenarioIds.includes(scenario.id)
));
const pairs = builds.flatMap((build) => scenarios
.filter((scenario) => scenario.roles.includes(build.role))
.map((scenario) => ({ build, scenario })));
if (!pairs.length) throw new Error("The selected filters produced no compatible build/scenario pairs.");
const totalRuns = pairs.length * options.seeds.length;
const runs = [];
let completed = 0;
for (const pair of pairs) {
for (const seed of options.seeds) {
completed += 1;
process.stdout.write(
`[${completed}/${totalRuns}] ${pair.build.classId}:${pair.build.role} / ${pair.scenario.id} / seed ${seed}\n`,
);
const run = await runCombatBenchmark(pair.build, pair.scenario, {
seed,
durationScale: options.durationScale,
});
runs.push(run);
if (!run.validation.valid) {
process.stderr.write(` invalid: ${run.validation.reasons.join("; ")}\n`);
}
}
}
const report: BenchmarkMatrixReport = {
schemaVersion: 1,
generatedAt,
appVersion: packageJson.version,
cohort: {
level: options.level,
gearPresetId: builds[0]?.gearPresetId ?? "coefficient-empty",
seeds: options.seeds,
durationScale: options.durationScale,
policyId: BENCHMARK_POLICY_ID,
},
scenarios,
builds,
runs,
};
const defaultOutput = resolve(projectRoot, "artifacts/combat-benchmarks", timestampDirectory(generatedAt));
const output = options.output ? resolve(process.cwd(), options.output) : defaultOutput;
mkdirSync(output, { recursive: true });
writeFileSync(resolve(output, "matrix.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
writeFileSync(resolve(output, "matrix.csv"), serializeBenchmarkMatrixCsv(report), "utf8");
writeFileSync(resolve(output, "summary.md"), renderBenchmarkMatrixMarkdown(report), "utf8");
process.stdout.write(`Wrote ${runs.length} run(s) to ${output}\n`);
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exitCode = 1;
});
+25 -11
View File
@@ -61,10 +61,13 @@ import {
import { import {
EquipmentAttachments, EquipmentAttachments,
equipmentAttachmentHand, equipmentAttachmentHand,
resolveEquipmentAttachmentTransforms,
} from "./equipmentAttachments"; } from "./equipmentAttachments";
import { import {
ApproximateStarterEquipment, ApproximateStarterEquipment,
StarterWeaponAttachments, StarterWeaponAttachments,
characterWeaponStance,
estimateAvatarWeaponScale,
type AvatarHand, type AvatarHand,
} from "./starterWeapons"; } from "./starterWeapons";
@@ -192,6 +195,14 @@ function LoadedCharacterModel({
}) { }) {
const gltf = useGLTF(resolveContentUrl(entry.model)); const gltf = useGLTF(resolveContentUrl(entry.model));
const scene = useMemo(() => cloneAvatarScene(gltf.scene), [gltf.scene]); 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 mixer = useMemo(() => new AnimationMixer(scene), [scene]);
const currentAction = useRef<AnimationAction | null>(null); const currentAction = useRef<AnimationAction | null>(null);
const overrideAction = useRef<AnimationAction | null>(null); const overrideAction = useRef<AnimationAction | null>(null);
@@ -199,7 +210,6 @@ function LoadedCharacterModel({
const overrideLooping = useRef(false); const overrideLooping = useRef(false);
const currentMotion = useRef<CharacterLocomotionMotion | null>(null); const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0); const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0); const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS); const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
const attackJoint = useMemo( const attackJoint = useMemo(
@@ -233,6 +243,15 @@ function LoadedCharacterModel({
}))] as AvatarHand[], }))] as AvatarHand[],
[resolved.equipmentModels], [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -339,7 +358,7 @@ function LoadedCharacterModel({
}; };
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => ( const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
selectCharacterBaseClip(gltf.animations, motion) selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion]) ?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle) ?? namedClip(entry.animations.idle)
?? gltf.animations[0] ?? gltf.animations[0]
@@ -347,7 +366,6 @@ function LoadedCharacterModel({
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => { const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return; if (overrideAction.current) return;
if (currentMotion.current === motion) return;
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing" const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving) ? selectCharacterVerticalClip(gltf.animations, motion, moving)
: null; : null;
@@ -356,6 +374,7 @@ function LoadedCharacterModel({
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion, motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
); );
if (!clip) return; if (!clip) return;
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
const loop = verticalSelection?.loop ?? true; const loop = verticalSelection?.loop ?? true;
const next = mixer.clipAction(clip, scene); const next = mixer.clipAction(clip, scene);
next.enabled = true; next.enabled = true;
@@ -462,15 +481,11 @@ function LoadedCharacterModel({
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) { if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
observedAnimationRevision.current = nextAnimationEvent.revision; observedAnimationRevision.current = nextAnimationEvent.revision;
playAnimationEvent(nextAnimationEvent); playAnimationEvent(nextAnimationEvent);
if (nextAnimationEvent.kind === "attack" || nextAnimationEvent.kind === "ability-release") {
equipmentAttackPulseRef.current = nextAnimationEvent.revision;
}
} }
const nextAttackPulse = attackPulseRef?.current ?? 0; const nextAttackPulse = attackPulseRef?.current ?? 0;
if (nextAttackPulse !== observedAttackPulse.current) { if (nextAttackPulse !== observedAttackPulse.current) {
observedAttackPulse.current = nextAttackPulse; observedAttackPulse.current = nextAttackPulse;
if (nextAttackPulse > 0) { if (nextAttackPulse > 0) {
equipmentAttackPulseRef.current = nextAttackPulse;
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" }); playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
} }
} }
@@ -513,17 +528,16 @@ function LoadedCharacterModel({
/> />
</group> </group>
<StarterWeaponAttachments <StarterWeaponAttachments
attackPulseRef={equipmentAttackPulseRef}
classId={identity.classId} classId={identity.classId}
entry={entry}
occupiedHands={occupiedEquipmentHands} occupiedHands={occupiedEquipmentHands}
root={scene} root={scene}
/> />
<EquipmentAttachments <EquipmentAttachments
attackPulseRef={equipmentAttackPulseRef}
classId={identity.classId}
entry={entry} entry={entry}
fallbackScale={fallbackEquipmentScale}
models={resolved.equipmentModels} 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"); 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", () => { it("isolates each optional gear model behind its own loading and error boundary", () => {
expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary"); expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary");
expect(equipmentAttachmentSource).toContain("<Suspense fallback={fallback}>"); 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, 0, 1, 4, 5, 8, 9, 10,
16, 17, 18, 19, 16, 17, 18, 19,
25, 26, 27, 28, 25, 26, 27, 28,
46, 46, 109,
51, 52, 53, 54, 51, 52, 53, 54,
57, 58, 95, 124, 125, 57, 58, 95, 124, 125,
] as const; ] as const;
+21 -1
View File
@@ -22,7 +22,7 @@ describe("resolveEquipmentAppearance", () => {
expect(resolved.models).toEqual(expect.arrayContaining([ expect(resolved.models).toEqual(expect.arrayContaining([
expect.objectContaining({ itemId: 6472, attachment: "handRight" }), 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: "shoulderLeft" }),
expect.objectContaining({ itemId: 5404, attachment: "shoulderRight" }), 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", () => { it("uses gendered robe textures, equipment geosets, and the actual cape skin", () => {
const resolved = resolveEquipmentAppearance([ const resolved = resolveEquipmentAppearance([
item(6465), item(6465),
+22 -3
View File
@@ -1,4 +1,5 @@
import type { GenderId } from "../app/characterCatalog"; import type { GenderId } from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import type { TextureRegion } from "./characterManifest"; import type { TextureRegion } from "./characterManifest";
@@ -33,6 +34,8 @@ export interface EquipmentModelVisual {
readonly name: string; readonly name: string;
readonly url: string; readonly url: string;
readonly attachment: EquipmentAttachmentName; readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
} }
interface GenderedTextureLayer { interface GenderedTextureLayer {
@@ -67,6 +70,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Firebelcher", name: "Firebelcher",
url: model("firebelcher-5243"), url: model("firebelcher-5243"),
attachment: "handRight", attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}], }],
}, },
5404: { 5404: {
@@ -101,7 +106,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{ models: [{
name: "Worn Turtle Shell Shield", name: "Worn Turtle Shell Shield",
url: model("worn-turtle-shell-shield-6447"), url: model("worn-turtle-shell-shield-6447"),
attachment: "handLeft", attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}], }],
}, },
6448: { 6448: {
@@ -109,6 +116,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Tail Spike", name: "Tail Spike",
url: model("tail-spike-6448"), url: model("tail-spike-6448"),
attachment: "handRight", attachment: "handRight",
fallbackKind: "dagger",
weaponStance: "one-handed",
}], }],
}, },
6449: { 6449: {
@@ -188,6 +197,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Venomstrike", name: "Venomstrike",
url: model("venomstrike-6469"), url: model("venomstrike-6469"),
attachment: "handLeft", attachment: "handLeft",
fallbackKind: "bow",
weaponStance: "bow",
}], }],
}, },
6472: { 6472: {
@@ -195,6 +206,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Stinging Viper", name: "Stinging Viper",
url: model("stinging-viper-6472"), url: model("stinging-viper-6472"),
attachment: "handRight", attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}], }],
}, },
6473: { 6473: {
@@ -237,7 +250,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{ models: [{
name: "Seedcloud Buckler", name: "Seedcloud Buckler",
url: model("seedcloud-buckler-6630"), url: model("seedcloud-buckler-6630"),
attachment: "handLeft", attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}], }],
}, },
6631: { 6631: {
@@ -245,6 +260,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Living Root", name: "Living Root",
url: model("living-root-6631"), url: model("living-root-6631"),
attachment: "handRight", attachment: "handRight",
fallbackKind: "staff",
weaponStance: "two-handed-left",
}], }],
}, },
6632: { 6632: {
@@ -291,7 +308,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{ models: [{
name: "Kresh's Back", name: "Kresh's Back",
url: model("kreshs-back-13245"), url: model("kreshs-back-13245"),
attachment: "handLeft", attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}], }],
}, },
}); });
+136 -73
View File
@@ -5,31 +5,47 @@ import {
Suspense, Suspense,
useEffect, useEffect,
useMemo, useMemo,
type MutableRefObject,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { import {
DoubleSide, DoubleSide,
Mesh, Mesh,
Object3D, Object3D,
SkinnedMesh,
Vector3,
type Material, type Material,
} from "three"; } from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import type { ClassId } from "../app/characterCatalog";
import type { AvatarManifestEntry } from "./characterManifest"; import type { AvatarManifestEntry } from "./characterManifest";
import type { import type {
EquipmentAttachmentName, EquipmentAttachmentName,
EquipmentModelVisual, EquipmentModelVisual,
} from "./equipmentAppearance"; } from "./equipmentAppearance";
import {
resolveAvatarAttachmentPoint,
type AvatarAttachmentTransform,
} from "./attachmentPoints";
import { AvatarErrorBoundary } from "./AvatarErrorBoundary"; import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
import { import {
avatarWeaponSocketRotation,
findAvatarHandBone, findAvatarHandBone,
StarterWeaponAttachments, findAvatarShieldBone,
StarterWeaponVisual,
type AvatarHand, type AvatarHand,
type StarterWeaponRotation,
} from "./starterWeapons"; } 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( export function equipmentAttachmentHand(
attachment: EquipmentAttachmentName, attachment: EquipmentAttachmentName,
): AvatarHand | null { ): AvatarHand | null {
@@ -58,23 +74,14 @@ function cloneEquipmentScene(source: Object3D, name: string): Object3D {
return scene; 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( function fallbackTarget(
root: Object3D, root: Object3D,
attachment: EquipmentAttachmentName, attachment: EquipmentAttachmentName,
): Object3D | null { ): Object3D | null {
if (attachment === "handLeft" || attachment === "shield") { if (attachment === "handLeft" || attachment === "shield") {
return findAvatarHandBone(root, "left"); return attachment === "shield"
? findAvatarShieldBone(root)
: findAvatarHandBone(root, "left");
} }
if (attachment === "handRight") return findAvatarHandBone(root, "right"); if (attachment === "handRight") return findAvatarHandBone(root, "right");
return root.getObjectByName( return root.getObjectByName(
@@ -82,43 +89,42 @@ function fallbackTarget(
) ?? null; ) ?? null;
} }
function attachmentTransform( /** Snapshots all authored attachment offsets once, before animation begins. */
export function resolveEquipmentAttachmentTransforms(
root: Object3D, root: Object3D,
entry: AvatarManifestEntry, entry: AvatarManifestEntry,
attachment: EquipmentAttachmentName, ): EquipmentAttachmentTransforms {
): { target: Object3D; position: Vector3 } | null { const transforms: Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>> = {};
const point = entry.attachmentPoints?.[attachment]; for (const attachment of EQUIPMENT_ATTACHMENT_NAMES) {
const target = point const transform = resolveAvatarAttachmentPoint(
? findSkinBone(root, point.bone) ?? fallbackTarget(root, attachment) root,
: fallbackTarget(root, attachment); entry.attachmentPoints?.[attachment],
if (!target) return null; fallbackTarget(root, attachment),
if (!point) return { target, position: new Vector3() }; {
useTargetOrigin: attachment === "handLeft"
root.updateWorldMatrix(true, true); || attachment === "handRight"
const position = root.localToWorld(new Vector3(...point.position)); || attachment === "shield",
target.worldToLocal(position); },
return { target, position }; );
if (transform) transforms[attachment] = transform;
}
return transforms;
} }
function AttachedEquipmentModel({ function AttachedEquipmentModel({
entry, entry,
itemModel, itemModel,
root, transform,
}: { }: {
readonly entry: AvatarManifestEntry; readonly entry: AvatarManifestEntry;
readonly itemModel: EquipmentModelVisual; readonly itemModel: EquipmentModelVisual;
readonly root: Object3D; readonly transform: AvatarAttachmentTransform;
}): ReactNode { }): ReactNode {
const gltf = useGLTF(resolveContentUrl(itemModel.url)); const gltf = useGLTF(resolveContentUrl(itemModel.url));
const scene = useMemo( const scene = useMemo(
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`), () => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
[gltf.scene, itemModel.attachment, itemModel.itemId], [gltf.scene, itemModel.attachment, itemModel.itemId],
); );
const transform = useMemo(
() => attachmentTransform(root, entry, itemModel.attachment),
[entry, itemModel.attachment, root],
);
useEffect(() => () => { useEffect(() => () => {
const materials = new Set<Material>(); const materials = new Set<Material>();
scene.traverse((node) => { scene.traverse((node) => {
@@ -130,52 +136,109 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose()); materials.forEach((material) => material.dispose());
}, [scene]); }, [scene]);
if (!transform) return null; const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
return createPortal( return createPortal(
<primitive object={scene} position={transform.position} />, <group
position={transform.position}
rotation={socketRotation}
scale={transform.scale}
>
<primitive object={scene} />
</group>,
transform.target, transform.target,
); );
} }
export function EquipmentAttachments({ function EquipmentModelFallback({
attackPulseRef,
classId,
entry, entry,
models, fallbackScale,
root, itemModel,
transform,
}: { }: {
readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId;
readonly entry: AvatarManifestEntry; 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 models: readonly EquipmentModelVisual[];
readonly root: Object3D; readonly transforms: EquipmentAttachmentTransforms;
}): ReactNode { }): ReactNode {
return models.map((itemModel) => { return models.map((itemModel) => {
const hand = equipmentAttachmentHand(itemModel.attachment); const transform = transforms[itemModel.attachment];
const fallback = hand return transform ? (
? ( <ResolvedEquipmentAttachment
<StarterWeaponAttachments
attackPulseRef={attackPulseRef}
classId={classId}
onlyHands={[hand]}
root={root}
/>
)
: null;
return (
<AvatarErrorBoundary
key={`${itemModel.itemId}:${itemModel.attachment}`} key={`${itemModel.itemId}:${itemModel.attachment}`}
fallback={fallback} entry={entry}
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`} fallbackScale={fallbackScale}
> itemModel={itemModel}
<Suspense fallback={fallback}> transform={transform}
<AttachedEquipmentModel />
entry={entry} ) : null;
itemModel={itemModel}
root={root}
/>
</Suspense>
</AvatarErrorBoundary>
);
}); });
} }
@@ -3,6 +3,19 @@ import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AvatarManifest } from "./characterManifest"; 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"); const manifestFile = path.resolve("public/assets/characters/runewaker-v1/manifest.json");
describe("runewaker-v1 playable avatar pack", () => { describe("runewaker-v1 playable avatar pack", () => {
@@ -32,6 +45,17 @@ describe("runewaker-v1 playable avatar pack", () => {
}); });
const model = path.resolve("public", entry.model.replace(/^\//, "")); const model = path.resolve("public", entry.model.replace(/^\//, ""));
expect((await stat(model)).size).toBe(entry.bytes); 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 { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Group, Object3D } from "three"; import { Euler, Group, Object3D, Vector3 } from "three";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CLASSES, type ClassId } from "../app/characterCatalog"; import { CLASSES, type ClassId } from "../app/characterCatalog";
import { import {
STARTER_WEAPON_LOADOUTS, STARTER_WEAPON_LOADOUTS,
approximateStarterWeaponGripRotation,
avatarWeaponSocketRotation,
characterWeaponStance,
estimateAvatarWeaponScale, estimateAvatarWeaponScale,
findAvatarHandBone, findAvatarHandBone,
findAvatarHandBones, findAvatarHandBones,
findAvatarShieldBone,
sampleStarterWeaponStrike, sampleStarterWeaponStrike,
starterWeaponItemsForAvailableHands, starterWeaponItemsForAvailableHands,
starterWeaponLoadout, starterWeaponLoadout,
starterWeaponModelRotation,
starterWeaponStance,
} from "./starterWeapons"; } from "./starterWeapons";
import type { AvatarManifestEntry } from "./characterManifest";
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
import { equipmentAttachmentHand } from "./equipmentAttachments"; import { equipmentAttachmentHand } from "./equipmentAttachments";
interface RawGlbNode { interface RawGlbNode {
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
expect(equipmentAttachmentHand("shield")).toBe("left"); expect(equipmentAttachmentHand("shield")).toBe("left");
expect(equipmentAttachmentHand("shoulderLeft")).toBeNull(); 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", () => { describe("avatar hand-bone discovery", () => {
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
expect(findAvatarHandBone(root, "right")).toBeNull(); 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", () => { it("estimates equipment scale in avatar-local space and ignores parent transforms", () => {
const actor = new Group(); const actor = new Group();
actor.position.set(20, 8, -12); 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", () => { 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, projectDirectory,
"public", "public",
"assets", "assets",
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
const entries = manifestPaths.flatMap((manifestPath) => ( const entries = manifestPaths.flatMap((manifestPath) => (
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries) Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
)); ));
expect(entries).toHaveLength(45); expect(entries).toHaveLength(50);
const resolvedLeftNames = new Set<string>(); const resolvedLeftNames = new Set<string>();
const resolvedRightNames = new Set<string>(); const resolvedRightNames = new Set<string>();
const unresolved: string[] = []; const unresolved: string[] = [];
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
// accidentally hardcodes one id cannot satisfy this diversity check. // accidentally hardcodes one id cannot satisfy this diversity check.
expect(resolvedLeftNames.size).toBeGreaterThan(8); expect(resolvedLeftNames.size).toBeGreaterThan(8);
expect(resolvedRightNames.size).toBeGreaterThan(8); expect(resolvedRightNames.size).toBeGreaterThan(8);
expect(resolvedLeftNames).toContain("p_left_hand");
expect(resolvedRightNames).toContain("p_right_hand");
}, 20_000); }, 20_000);
}); });
+228 -27
View File
@@ -7,6 +7,10 @@ import {
type BaseClassId, type BaseClassId,
type ClassId, type ClassId,
} from "../app/characterCatalog"; } 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"; export type AvatarHand = "left" | "right";
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
export interface StarterWeaponLoadout { export interface StarterWeaponLoadout {
readonly classId: ClassId; readonly classId: ClassId;
readonly items: readonly StarterWeaponItem[]; readonly items: readonly StarterWeaponItem[];
readonly stance: StarterWeaponStance;
} }
export type StarterWeaponStance = CharacterWeaponStance;
const item = ( const item = (
id: string, id: string,
kind: StarterWeaponKind, kind: StarterWeaponKind,
@@ -45,6 +52,7 @@ const item = (
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
warrior: Object.freeze({ warrior: Object.freeze({
classId: "warrior", classId: "warrior",
stance: "one-handed",
items: Object.freeze([ items: Object.freeze([
item("warrior-sword", "sword", "right", 1.04), item("warrior-sword", "sword", "right", 1.04),
item("warrior-shield", "shield", "left", 0.96), item("warrior-shield", "shield", "left", 0.96),
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}), }),
paladin: Object.freeze({ paladin: Object.freeze({
classId: "paladin", classId: "paladin",
stance: "one-handed",
items: Object.freeze([ items: Object.freeze([
item("paladin-mace", "mace", "right", 1.04), item("paladin-mace", "mace", "right", 1.04),
item("paladin-shield", "shield", "left", 0.98), item("paladin-shield", "shield", "left", 0.98),
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}), }),
hunter: Object.freeze({ hunter: Object.freeze({
classId: "hunter", classId: "hunter",
stance: "bow",
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]), items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
}), }),
rogue: Object.freeze({ rogue: Object.freeze({
classId: "rogue", classId: "rogue",
stance: "one-handed",
items: Object.freeze([ items: Object.freeze([
item("rogue-main-dagger", "dagger", "right", 0.94), item("rogue-main-dagger", "dagger", "right", 0.94),
item("rogue-off-dagger", "dagger", "left", 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({ priest: Object.freeze({
classId: "priest", classId: "priest",
stance: "two-handed-left",
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]), items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
}), }),
"death-knight": Object.freeze({ "death-knight": Object.freeze({
classId: "death-knight", classId: "death-knight",
stance: "two-handed",
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]), items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
}), }),
shaman: Object.freeze({ shaman: Object.freeze({
classId: "shaman", classId: "shaman",
stance: "one-handed",
items: Object.freeze([ items: Object.freeze([
item("shaman-mace", "mace", "right", 1), item("shaman-mace", "mace", "right", 1),
item("shaman-shield", "shield", "left", 0.9), item("shaman-shield", "shield", "left", 0.9),
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}), }),
mage: Object.freeze({ mage: Object.freeze({
classId: "mage", classId: "mage",
stance: "two-handed-left",
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]), items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
}), }),
warlock: Object.freeze({ warlock: Object.freeze({
classId: "warlock", classId: "warlock",
stance: "two-handed-left",
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]), items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
}), }),
druid: Object.freeze({ druid: Object.freeze({
classId: "druid", classId: "druid",
stance: "two-handed-left",
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]), 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({ return [characterClass.id, Object.freeze({
classId: characterClass.id, classId: characterClass.id,
items: source.items, items: source.items,
stance: source.stance,
})]; })];
})) as unknown as Record<ClassId, StarterWeaponLoadout>, })) as unknown as Record<ClassId, StarterWeaponLoadout>,
); );
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
return STARTER_WEAPON_LOADOUTS[classId]; 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( export function starterWeaponItemsForAvailableHands(
classId: ClassId, classId: ClassId,
occupiedHands: readonly AvatarHand[] = [], 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, 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({ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
left: Object.freeze([ left: Object.freeze([
/(?:^|[_ .-])L Hand$/i, /(?:^|[_ .-])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 * Finds a playable-character grip without relying on exporter-specific numeric
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender * bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
* export, and their shared parent is the animated hand bone. * named finger pivots otherwise identify their shared animated hand bone.
*/ */
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null { 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>(); const votes = new Map<Object3D, number>();
root.traverse((node) => { root.traverse((node) => {
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return; 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; 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 { export interface AvatarHandBones {
readonly left: Object3D | null; readonly left: Object3D | null;
readonly right: Object3D | null; readonly right: Object3D | null;
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
export type StarterWeaponRotation = readonly [x: number, y: number, z: number]; export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]); 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 STRIKE_DURATION_SECONDS = 0.54;
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
@@ -374,16 +502,16 @@ function BowVisual() {
function ShieldVisual() { function ShieldVisual() {
return ( return (
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}> <group name="starter-shield-geometry" position={[0, 0, -0.08]}>
<mesh rotation={[0, 0, Math.PI / 2]}> <mesh rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} /> <cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} /> <meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
</mesh> </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]} /> <cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} /> <meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
</mesh> </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]} /> <torusGeometry args={[0.3, 0.035, 6, 16]} />
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} /> <meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
</mesh> </mesh>
@@ -392,12 +520,22 @@ function ShieldVisual() {
} }
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) { function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
if (kind === "bow") return <BowVisual />; const visual = kind === "bow"
if (kind === "dagger") return <DaggerVisual />; ? <BowVisual />
if (kind === "mace") return <MaceVisual />; : kind === "dagger"
if (kind === "shield") return <ShieldVisual />; ? <DaggerVisual />
if (kind === "staff") return <StaffVisual />; : kind === "mace"
return <SwordVisual />; ? <MaceVisual />
: kind === "shield"
? <ShieldVisual />
: kind === "staff"
? <StaffVisual />
: <SwordVisual />;
return (
<group rotation={starterWeaponModelRotation(kind)}>
{visual}
</group>
);
} }
export interface StarterWeaponVisualProps { export interface StarterWeaponVisualProps {
@@ -449,35 +587,88 @@ export function StarterWeaponVisual({
interface AttachedStarterWeaponProps { interface AttachedStarterWeaponProps {
readonly attackPulse: number; readonly attackPulse: number;
readonly attackPulseRef?: MutableRefObject<number>; readonly attackPulseRef?: MutableRefObject<number>;
readonly attachmentScale: Vector3;
readonly item: StarterWeaponItem; readonly item: StarterWeaponItem;
readonly position: Vector3;
readonly scale: number; readonly scale: number;
readonly socketRotation: StarterWeaponRotation;
readonly target: Object3D; 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({ function AttachedStarterWeapon({
attackPulse, attackPulse,
attackPulseRef, attackPulseRef,
attachmentScale,
item: equipped, item: equipped,
position,
scale, scale,
socketRotation,
target, target,
}: AttachedStarterWeaponProps) { }: AttachedStarterWeaponProps) {
return createPortal( return createPortal(
<StarterWeaponVisual <group position={position} rotation={socketRotation} scale={attachmentScale}>
attackPulse={attackPulse} <StarterWeaponVisual
attackPulseRef={attackPulseRef} attackPulse={attackPulse}
hand={equipped.hand} attackPulseRef={attackPulseRef}
kind={equipped.kind} hand={equipped.hand}
name={`starter-equipment-${equipped.id}`} kind={equipped.kind}
scale={scale * equipped.scale} name={`starter-equipment-${equipped.id}`}
/>, scale={scale * equipped.scale}
/>
</group>,
target, 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 { export interface StarterWeaponAttachmentsProps {
readonly attackPulse?: number; readonly attackPulse?: number;
readonly attackPulseRef?: MutableRefObject<number>; readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId; readonly classId: ClassId;
readonly entry?: AvatarManifestEntry;
readonly occupiedHands?: readonly AvatarHand[]; readonly occupiedHands?: readonly AvatarHand[];
readonly onlyHands?: readonly AvatarHand[]; readonly onlyHands?: readonly AvatarHand[];
readonly root: Object3D; readonly root: Object3D;
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
attackPulse = 0, attackPulse = 0,
attackPulseRef, attackPulseRef,
classId, classId,
entry,
occupiedHands = [], occupiedHands = [],
onlyHands, onlyHands,
root, root,
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
[hands, root, scale], [hands, root, scale],
); );
const allowedHands = onlyHands ? new Set(onlyHands) : null; const allowedHands = onlyHands ? new Set(onlyHands) : null;
const stance = starterWeaponStance(classId);
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands) const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
.filter((item) => !allowedHands || allowedHands.has(item.hand)); .filter((item) => !allowedHands || allowedHands.has(item.hand));
return items.map((equipped) => { return items.map((equipped) => {
const target = hands[equipped.hand]; const attachmentName = equipped.kind === "shield"
return target ? ( ? "shield"
<AttachedStarterWeapon : equipped.hand === "left" ? "handLeft" : "handRight";
const fallbackTarget = equipped.kind === "shield"
? findAvatarShieldBone(root)
: hands[equipped.hand];
return (
<ResolvedStarterWeapon
key={equipped.id} key={equipped.id}
attackPulse={attackPulse} attackPulse={attackPulse}
attackPulseRef={attackPulseRef} attackPulseRef={attackPulseRef}
fallbackTarget={fallbackTarget}
item={equipped} item={equipped}
point={entry?.attachmentPoints?.[attachmentName]}
root={root}
scale={equipmentScale} scale={equipmentScale}
target={target} socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
/> />
) : null; );
}); });
} }
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
<group <group
key={equipped.id} key={equipped.id}
position={handPositions[equipped.hand]} position={handPositions[equipped.hand]}
rotation={[0, -Math.PI / 2, 0]} rotation={approximateStarterWeaponGripRotation(equipped.kind)}
> >
<StarterWeaponVisual <StarterWeaponVisual
attackPulse={attackPulse} 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 { useEffect, useRef } from "react";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { advanceDungeonPartyCombat } from "./partyRuntime"; import { advanceDungeonPartyCombat } from "./partyRuntime";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
@@ -10,6 +11,7 @@ import {
onlineSessionIsActive, onlineSessionIsActive,
onlineSessionOwnsWorldSimulation, onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime"; } from "./onlineSessionRuntime";
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
/** /**
* Connects the pure combat runtime to the active character and persistent * Connects the pure combat runtime to the active character and persistent
@@ -42,73 +44,55 @@ export function CombatBridge() {
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
let lastKey = `${character.level}:${character.experience}:${JSON.stringify(character.talentRanks)}`; const characterId = character.id;
const unsubscribe = useCombatStore.subscribe((state) => { const unsubscribe = useCombatStore.subscribe((state, previousState) => {
const nextKey = `${state.level}:${state.xp}:${JSON.stringify(state.talentRanks)}`; // Save APIs intentionally target the active character. Avoid persisting a
if (nextKey === lastKey) return; // final combat notification during the render-to-effect-cleanup window
lastKey = nextKey; // after Character Select has already replaced the active character.
// Kills and talent changes are infrequent, so persist immediately. This const shell = useShellStore.getState();
// also guarantees a fast return to Character Select cannot outrun a if (shell.activeCharacter?.id !== characterId) return;
// deferred save and lose the last level or talent point.
useShellStore.getState().saveActiveProgression(state.level, state.xp, state.talentRanks);
});
return unsubscribe;
}, [character?.id]);
useEffect(() => { // Persisted collections are replaced immutably by combat actions. A
if (!character) return; // reference/primitive comparison therefore detects the same changes as
let lastKey = JSON.stringify(useCombatStore.getState().actionBindings); // serializing each collection, without doing five full-store callbacks
const unsubscribe = useCombatStore.subscribe((state) => { // and JSON.stringify work for every 100 ms simulation notification.
const nextKey = JSON.stringify(state.actionBindings); if (
if (nextKey === lastKey) return; state.level !== previousState.level
lastKey = nextKey; || state.xp !== previousState.xp
// Manastorm's local spell-selection substitute temporarily rewrites the || state.talentRanks !== previousState.talentRanks
// base controls. Never persist that run-only overlay to normal dungeons. ) {
if (useGameStore.getState().gameMode === "manastorm") return; // Kills and talent changes are infrequent, so persist immediately. This
useShellStore.getState().saveActiveActionBindings(state.actionBindings); // also guarantees a fast return to Character Select cannot outrun a
}); // deferred save and lose the last level or talent point.
return unsubscribe; shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
}, [character?.id]); }
useEffect(() => { if (state.actionBindings !== previousState.actionBindings) {
if (!character) return; // Manastorm's local spell-selection substitute temporarily rewrites the
let lastInventory = JSON.stringify(character.inventory); // base controls. Never persist that run-only overlay to normal dungeons.
const unsubscribe = useCombatStore.subscribe((state) => { if (useGameStore.getState().gameMode !== "manastorm") {
const nextInventory = JSON.stringify(state.inventory); shell.saveActiveActionBindings(state.actionBindings);
if (nextInventory === lastInventory) return; }
lastInventory = nextInventory; }
useShellStore.getState().saveActiveInventory(state.inventory);
});
return unsubscribe;
}, [character?.id]);
useEffect(() => { if (state.inventory !== previousState.inventory) {
if (!character) return; shell.saveActiveInventory(state.inventory);
let lastEquipment = JSON.stringify(character.equipment ?? {}); }
const unsubscribe = useCombatStore.subscribe((state) => { if (state.equipment !== previousState.equipment) {
const nextEquipment = JSON.stringify(state.equipment); shell.saveActiveEquipment(state.equipment);
if (nextEquipment === lastEquipment) return; }
lastEquipment = nextEquipment; if (state.settings !== previousState.settings) {
useShellStore.getState().saveActiveEquipment(state.equipment); shell.saveActiveSettings(state.settings);
}); }
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);
}); });
return unsubscribe; return unsubscribe;
}, [character?.id]); }, [character?.id]);
useEffect(() => { useEffect(() => {
let previous = performance.now(); let previous = performance.now();
const timer = window.setInterval(() => { let timer: number | null = null;
const advance = () => {
const now = performance.now(); const now = performance.now();
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000)); const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
previous = now; 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; 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: "Fall (ID 40 variation 0)" },
{ name: "JumpEnd (ID 39 variation 0)" }, { name: "JumpEnd (ID 39 variation 0)" },
{ name: "JumpLandRun (ID 133 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", () => { describe("WoW character animation selection", () => {
@@ -43,6 +48,31 @@ describe("WoW character animation selection", () => {
expect(selectCharacterBaseClip(clips, "run")).toBe(clips[2]); 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", () => { it("rotates every variation of the highest-priority available ID", () => {
expect(selectWoWAnimationVariation(clips, [16], 0)).toBe(clips[9]); expect(selectWoWAnimationVariation(clips, [16], 0)).toBe(clips[9]);
expect(selectWoWAnimationVariation(clips, [16], 1)).toBe(clips[10]); 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 CharacterBaseMotion = "idle" | "walk" | "run";
export type CharacterWeaponStance =
| "bow"
| "one-handed"
| "two-handed"
| "two-handed-left";
export interface CharacterClipSelection<TClip extends NamedAnimationClip> { export interface CharacterClipSelection<TClip extends NamedAnimationClip> {
readonly clip: TClip; readonly clip: TClip;
readonly loop: boolean; readonly loop: boolean;
@@ -108,7 +114,21 @@ export function selectWoWAnimationVariation<TClip extends NamedAnimationClip>(
export function selectCharacterBaseClip<TClip extends NamedAnimationClip>( export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[], clips: readonly TClip[],
motion: CharacterBaseMotion, motion: CharacterBaseMotion,
weaponStance?: CharacterWeaponStance,
): TClip | undefined { ): 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]; const ids = motion === "run" ? [5, 4, 0] : motion === "walk" ? [4, 5, 0] : [0, 4, 5];
return selectWoWAnimationVariation(clips, ids); 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 masterGain: GainNode | null = null;
let groupGains: Record<CombatActorGroup, GainNode> | null = null; let groupGains: Record<CombatActorGroup, GainNode> | null = null;
let configuredSettings: CombatPresentationSettings | null = null; let configuredSettings: CombatPresentationSettings | null = null;
let configuredMasterVolume = 0.8; let configuredMasterVolume = 0;
const activeVoices: ActiveVoice[] = []; const activeVoices: ActiveVoice[] = [];
const activeMasterVoices = new Set<() => void>(); const activeMasterVoices = new Set<() => void>();
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>(); 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 { function audioContextConstructor(): typeof AudioContext | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
return window.AudioContext return window.AudioContext
@@ -66,7 +90,11 @@ export function configureCombatAudio(
configuredSettings = settings; configuredSettings = settings;
configuredMasterVolume = Number.isFinite(masterVolume) configuredMasterVolume = Number.isFinite(masterVolume)
? Math.max(0, Math.min(1, 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(); applyCombatAudioSettings();
} }
@@ -113,6 +141,10 @@ const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
}; };
function playSynthesizedCombatCue(event: CombatPresentationEvent): void { function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const audioContext = ensureContext(); const audioContext = ensureContext();
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return; if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
void audioContext.resume().catch(() => undefined); void audioContext.resume().catch(() => undefined);
@@ -181,9 +213,15 @@ export async function playMasterAudioCue(
url: string, url: string,
volume = 1, volume = 1,
): Promise<(() => void) | null> { ): Promise<(() => void) | null> {
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
const audioContext = ensureContext(); const audioContext = ensureContext();
const buffer = await decodeAudio(url); const buffer = await decodeAudio(url);
if (!audioContext || !buffer || !masterGain) return null; if (
!masterAudioOutputEnabled(configuredMasterVolume)
|| !audioContext
|| !buffer
|| !masterGain
) return null;
void audioContext.resume().catch(() => undefined); void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource(); const source = audioContext.createBufferSource();
const envelope = audioContext.createGain(); const envelope = audioContext.createGain();
@@ -211,9 +249,19 @@ export async function playMasterAudioCue(
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> { async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
if (!sound.url) return false; if (!sound.url) return false;
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return false;
const audioContext = ensureContext(); const audioContext = ensureContext();
const buffer = await decodeAudio(sound.url); 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); void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource(); const source = audioContext.createBufferSource();
const envelope = audioContext.createGain(); const envelope = audioContext.createGain();
@@ -248,7 +296,10 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
stopCombatAudioGroup(event.actorGroup); stopCombatAudioGroup(event.actorGroup);
return; return;
} }
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return; if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event); const sound = weightedSound(event);
if (!sound) { if (!sound) {
playSynthesizedCombatCue(event); playSynthesizedCombatCue(event);
+2
View File
@@ -132,6 +132,8 @@ export interface AuraProcState {
export interface AuraDefinition { export interface AuraDefinition {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
/** Player-facing explanation shown by aura tooltips. */
readonly description?: string;
readonly disposition: AuraDisposition; readonly disposition: AuraDisposition;
/** Spell artwork used by every generic aura HUD surface. */ /** Spell artwork used by every generic aura HUD surface. */
readonly icon?: string; 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 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( export function appendCombatEvent(
events: readonly StructuredCombatEvent[], events: readonly StructuredCombatEvent[],
event: CombatEventInput, event: CombatEventInput,
@@ -45,5 +65,6 @@ export function appendCombatEvent(
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0), overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 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)))); 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 { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats"; import { deriveCharacterStats } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
function allocateTalentRanks(name: string, ranks: number): void { function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name); 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", () => { it("allocates the first WotLK talent point at level 10 and clamps settings", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 }); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
expect(useCombatStore.getState().settings.masterVolume).toBe(0);
const holy = talentNodesForTree("priest-holy")[0]; const holy = talentNodesForTree("priest-holy")[0];
const shadow = talentNodesForTree("priest-shadow")[0]; const shadow = talentNodesForTree("priest-shadow")[0];
expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true); expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true);
@@ -869,12 +871,44 @@ describe("combat store", () => {
showMobAggroRanges: true, showMobAggroRanges: true,
threatMeterPosition: { x: 0, y: 1 }, 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" }); useCombatStore.getState().initializeCharacter({ classId: "priest" });
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false); 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(); useCombatStore.getState().resetSettings();
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS); 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", () => { it("keeps a CoA tree ability uncastable until its exact node is purchased", () => {
useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 }); useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 });
const ability = abilitiesForClass("stormbringer").find((candidate) => ( 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"); 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", () => { it("resets encounter health without erasing earned progression", () => {
registerTarget("mob", [2, 0, 0], 1, 100); registerTarget("mob", [2, 0, 0], 1, 100);
useCombatStore.getState().damageMob("mob", 5, 1000); useCombatStore.getState().damageMob("mob", 5, 1000);
+124 -47
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { import {
baseClassFor, baseClassFor,
@@ -212,6 +213,14 @@ import {
normalizeHudPosition, normalizeHudPosition,
type NormalizedHudPosition, type NormalizedHudPosition,
} from "./hudLayout"; } 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"; export type MinimapRotation = "north-up" | "player-up";
@@ -227,6 +236,8 @@ export interface GameplaySettings {
readonly masterVolume: number; readonly masterVolume: number;
readonly uiScale: number; readonly uiScale: number;
readonly minimapRotation: MinimapRotation; readonly minimapRotation: MinimapRotation;
readonly gameplayFrameRate: GameplayFrameRateLimit;
readonly previewFrameRate: PreviewFrameRateLimit;
} }
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
@@ -238,9 +249,11 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showFloatingCombatText: true, showFloatingCombatText: true,
autoTargetNearest: true, autoTargetNearest: true,
enableScreenShake: true, enableScreenShake: true,
masterVolume: 0.8, masterVolume: 0,
uiScale: 1, uiScale: 1,
minimapRotation: "player-up", 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. */ /** 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. */ /** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16; export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20; 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 { export interface MobStatus {
readonly kind: MobStatusKind; readonly kind: MobStatusKind;
@@ -727,6 +742,8 @@ interface MutableCastWork {
gearStats: ItemStats; gearStats: ItemStats;
raceId: RaceId; raceId: RaceId;
secondaryClassId: ClassId | null; secondaryClassId: ClassId | null;
/** Active synchronous spell/source/target keys used to break proc cycles. */
triggeredSpellStack?: string[];
} }
let feedbackSequence = 0; let feedbackSequence = 0;
@@ -1600,9 +1617,34 @@ function normalizedSettings(
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up" minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
? patch.minimapRotation ? patch.minimapRotation
: current.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( function defaultMobStats(
playerLevel: number, playerLevel: number,
boss: boolean, boss: boolean,
@@ -1829,6 +1871,7 @@ function auraDefinitionForAbility(
): AuraDefinition { ): AuraDefinition {
return { return {
...definition, ...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon, icon: definition.icon ?? ability.icon,
durationMs, durationMs,
}; };
@@ -1974,15 +2017,30 @@ function executeTriggeredAuraProcsInWork(
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill") && (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
&& typeof action.data?.spellId === "number" && typeof action.data?.spellId === "number"
) { ) {
executeTriggeredSpellInWork( const spellId = action.data.spellId;
work, const targetId = triggered.event.otherId ?? triggered.targetId;
action.data.spellId, const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
triggered.sourceId, const triggeredSpellStack = work.triggeredSpellStack ?? [];
triggered.event.otherId ?? triggered.targetId, if (
now, triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
0, || triggeredSpellStack.includes(triggerKey)
true, ) 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 ( } else if (
action.kind === "custom" action.kind === "custom"
&& action.id === "classic-status" && action.id === "classic-status"
@@ -2140,7 +2198,7 @@ function grantMobExperience(
work.raceId, work.raceId,
work.secondaryClassId, 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; return scaledAward;
} }
@@ -2696,6 +2754,8 @@ function executeTriggeredSpellInWork(
definition: { definition: {
id: `coa-triggered-shield:${spellId}`, id: `coa-triggered-shield:${spellId}`,
name: ability.name, name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff", disposition: "buff",
durationMs: effect.durationMs, durationMs: effect.durationMs,
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }], absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
@@ -3152,7 +3212,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
resourceName: resource.name, resourceName: resource.name,
abilities, abilities,
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [], deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()), auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks, talentRanks,
talentModifiers, talentModifiers,
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)), 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]; const mob = get().mobs[id];
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false; if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS); 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 && ( const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04 (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 { return {
playerPosition: next, playerPosition: next,
activeCast: movedWhileCasting ? null : cast, activeCast: movedWhileCasting ? null : cast,
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, { combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(), occurredAt: combatNow(),
kind: "interrupt", kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID, sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: 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; const pending = get().pendingGroundTarget;
if (!pending) return emptyCastResult("invalid-destination"); if (!pending) return emptyCastResult("invalid-destination");
const destination = position ?? pending.position; 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) const request: CastRequest = Array.isArray(requestOrOrigin)
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast } ? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast }; : (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
@@ -4425,6 +4491,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: { definition: {
id: `shield:${ability.id}`, id: `shield:${ability.id}`,
name: ability.name, name: ability.name,
description: ability.description,
icon: ability.icon, icon: ability.icon,
disposition: "buff", disposition: "buff",
durationMs, durationMs,
@@ -5090,7 +5157,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return get().castAbility(abilityId, origin, now); 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 state = get();
const threatSource = source ?? playerThreatSource(state); const threatSource = source ?? playerThreatSource(state);
const progression = progressionFromState(state); const progression = progressionFromState(state);
@@ -5206,7 +5273,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return { mobs }; return { mobs };
}), }),
tauntMob: (id, source, durationMs, now = Date.now()) => { tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id]; const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) return false; if (!target || target.dead || durationMs <= 0) return false;
const previous = target.tauntDiminishingReturns; const previous = target.tauntDiminishingReturns;
@@ -5257,7 +5324,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return effectiveDurationMs > 0; return effectiveDurationMs > 0;
}, },
interruptMob: (id, sourceAbilityId, lockoutMs, now = Date.now()) => { interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
let interrupted = false; let interrupted = false;
set((state) => { set((state) => {
const mob = state.mobs[id]; const mob = state.mobs[id];
@@ -5273,7 +5340,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return interrupted; return interrupted;
}, },
controlMob: (id, sourceAbilityId, status, durationMs, now = Date.now()) => { controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
let controlled = false; let controlled = false;
set((state) => { set((state) => {
const mob = state.mobs[id]; const mob = state.mobs[id];
@@ -5331,7 +5398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, },
healActor: (id, amount, request) => { healActor: (id, amount, request) => {
const now = request.now ?? Date.now(); const now = request.now ?? combatNow();
const state = get(); const state = get();
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId); const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
const targetAuras = aurasForEntity(state.auras, id); const targetAuras = aurasForEntity(state.auras, id);
@@ -5414,7 +5481,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return resolution; return resolution;
}, },
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = Date.now()) => { reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
const state = get(); const state = get();
const fraction = clamp(percentMaxHealth, 0.01, 1); const fraction = clamp(percentMaxHealth, 0.01, 1);
if (id === PLAYER_AURA_ENTITY_ID) { if (id === PLAYER_AURA_ENTITY_ID) {
@@ -5494,7 +5561,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored; 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 state = get();
const actor = state.actors[id]; const actor = state.actors[id];
if (!actor) return 0; if (!actor) return 0;
@@ -5533,7 +5600,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored; return restored;
}, },
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = Date.now()) => { shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
const state = get(); const state = get();
const absorb = Math.max(0, Math.round(amount)); const absorb = Math.max(0, Math.round(amount));
if (absorb <= 0 || !state.actors[id]) return 0; if (absorb <= 0 || !state.actors[id]) return 0;
@@ -5541,6 +5608,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: { definition: {
id: `shield:${sourceActorId}:${ability.id}`, id: `shield:${sourceActorId}:${ability.id}`,
name: ability.name, name: ability.name,
description: ability.description,
icon: ability.icon, icon: ability.icon,
disposition: "buff", disposition: "buff",
durationMs: Math.max(1, durationMs), durationMs: Math.max(1, durationMs),
@@ -5570,7 +5638,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return absorb; return absorb;
}, },
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => { applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get(); const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false; if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
const durationMs = effect.aura.durationMs; const durationMs = effect.aura.durationMs;
@@ -5600,7 +5668,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return true; return true;
}, },
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => { applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get(); const state = get();
if (!state.actors[sourceActorId]) return false; if (!state.actors[sourceActorId]) return false;
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) 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; 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 state = get();
const owner = state.actors[ownerActorId]; const owner = state.actors[ownerActorId];
if (!owner?.alive) return null; if (!owner?.alive) return null;
@@ -5671,7 +5739,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return id; return id;
}, },
createActorAreaEffect: (sourceActorId, ability, position, now = Date.now()) => { createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
const state = get(); const state = get();
const sourceActor = state.actors[sourceActorId]; const sourceActor = state.actors[sourceActorId];
if (!sourceActor?.alive) return null; if (!sourceActor?.alive) return null;
@@ -5716,7 +5784,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
damagePlayer: (amount, school = "physical", attackerLevel) => { damagePlayer: (amount, school = "physical", attackerLevel) => {
const state = get(); const state = get();
const now = Date.now(); const now = combatNow();
const mitigated = damageAfterEquipmentMitigation( const mitigated = damageAfterEquipmentMitigation(
talentAdjustedIncomingDamage(amount, state.talentModifiers), talentAdjustedIncomingDamage(amount, state.talentModifiers),
state.gearStats, state.gearStats,
@@ -5775,6 +5843,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: { definition: {
id: "rom-state-revival-charge", id: "rom-state-revival-charge",
name: "Revival", name: "Revival",
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
disposition: "buff", disposition: "buff",
durationMs: null, durationMs: null,
maxStacks: 10, maxStacks: 10,
@@ -5831,7 +5900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: id, targetId: id,
amount: requested, amount: requested,
school: "physical", school: "physical",
now: Date.now(), now: combatNow(),
}); });
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage))); applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
const health = summon.health - applied; const health = summon.health - applied;
@@ -5846,7 +5915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
summons, summons,
actors, actors,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(), occurredAt: combatNow(),
kind: "damage", kind: "damage",
sourceActorId: "external", sourceActorId: "external",
targetActorId: id, targetActorId: id,
@@ -5878,12 +5947,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
subjectId: PLAYER_AURA_ENTITY_ID, subjectId: PLAYER_AURA_ENTITY_ID,
otherId: PLAYER_AURA_ENTITY_ID, otherId: PLAYER_AURA_ENTITY_ID,
amount: healed, 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({ set({
auras: procResult.auras, auras: procResult.auras,
health: state.health + healed, health: state.health + healed,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(), occurredAt: combatNow(),
kind: "healing", kind: "healing",
sourceActorId: PLAYER_AURA_ENTITY_ID, sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID,
@@ -5920,7 +5989,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
set((state) => ({ set((state) => ({
activeCast: null, activeCast: null,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(), occurredAt: combatNow(),
kind: "interrupt", kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID, sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: 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), playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }), 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; return true;
}, },
engageNearbyMobs: (now = Date.now(), playerPosition) => { engageNearbyMobs: (now = combatNow(), playerPosition) => {
const state = get(); const state = get();
const runtimeState = playerPosition const runtimeState = playerPosition
? { ...state, playerPosition } ? { ...state, playerPosition }
@@ -5960,7 +6029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return engagedGroups; return engagedGroups;
}, },
advanceMobCombat: (now = Date.now(), playerPosition) => { advanceMobCombat: (now = combatNow(), playerPosition) => {
const state = get(); const state = get();
const runtimeState = playerPosition const runtimeState = playerPosition
? { ...state, playerPosition } ? { ...state, playerPosition }
@@ -6237,6 +6306,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: { definition: {
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`, id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
name: `${attack.name}: ${effect.mechanic}`, name: `${attack.name}: ${effect.mechanic}`,
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
disposition: "debuff", disposition: "debuff",
durationMs: effect.durationMs, durationMs: effect.durationMs,
dispelCategory: "magic", dispelCategory: "magic",
@@ -6828,7 +6898,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return appliedDamage; return appliedDamage;
}, },
tick: (deltaSeconds, now = Date.now()) => { tick: (deltaSeconds, now = combatNow()) => {
const partyHotTicks: Array<{ const partyHotTicks: Array<{
memberId: string; memberId: string;
amount: number; amount: number;
@@ -7687,7 +7757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selectedTargetId: null, selectedTargetId: null,
selectedFriendlyActorId: null, selectedFriendlyActorId: null,
effects: [], effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()), auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
summons: [], summons: [],
areaEffects: [], areaEffects: [],
pendingGroundTarget: null, 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), health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource, maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, 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."), feedback: nextFeedback("talent", "Conquest node rank allocated."),
}); });
return true; 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), health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource, maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, 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."), 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) })), updateSettings: (patch) => set((state) => {
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }), 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 { export function selectedMob(state: CombatState): MobCombatState | null {
@@ -7815,14 +7892,14 @@ export function selectedMob(state: CombatState): MobCombatState | null {
export function cooldownRemainingMs( export function cooldownRemainingMs(
state: Pick<CombatState, "cooldowns">, state: Pick<CombatState, "cooldowns">,
abilityId: string, abilityId: string,
now = Date.now(), now = combatNow(),
): number { ): number {
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now); return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
} }
export function globalCooldownRemainingMs( export function globalCooldownRemainingMs(
state: Pick<CombatState, "globalCooldownEndsAt">, state: Pick<CombatState, "globalCooldownEndsAt">,
now = Date.now(), now = combatNow(),
): number { ): number {
return Math.max(0, state.globalCooldownEndsAt - now); return Math.max(0, state.globalCooldownEndsAt - now);
} }
@@ -7842,7 +7919,7 @@ useCombatStore.subscribe((state) => {
collectingBossLoot = true; collectingBossLoot = true;
try { try {
for (const boss of lootableBosses) { for (const boss of lootableBosses) {
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now()); useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
} }
} finally { } finally {
collectingBossLoot = false; 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 { import {
emitControllerToken, emitControllerToken,
orderedControllerTokenTransitions, orderedControllerTokenTransitions,
resetControllerState, resetControllerState,
startControllerInput,
subscribeControllerToken, subscribeControllerToken,
type ControllerTokenEvent, type ControllerTokenEvent,
} from "./controller"; } from "./controller";
@@ -67,4 +68,85 @@ describe("controller button phases", () => {
{ token: "Button0", repeat: false, pressed: true }, { 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 REPEAT_MS = 90;
const AXIS_THRESHOLD = 0.55; const AXIS_THRESHOLD = 0.55;
const AXIS_STEPS = 128; 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 listeners = new Set<TokenListener>();
const movementListeners = new Set<MovementListener>(); const movementListeners = new Set<MovementListener>();
@@ -39,26 +41,38 @@ let stopService: (() => void) | null = null;
function connectedGamepad(): Gamepad | null { function connectedGamepad(): Gamepad | null {
const gamepads = navigator.getGamepads?.(); const gamepads = navigator.getGamepads?.();
if (!gamepads) return null; 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 { function tokensFor(gamepad: Gamepad, tokens: Set<string>): void {
tokens.clear(); 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}`); 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}-`);
if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`); if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`);
}); }
} }
function canRepeat(token: string): boolean { function canRepeat(token: string): boolean {
return ["Button12", "Button13", "Button14", "Button15"].includes(token) return REPEATABLE_BUTTON_TOKENS.has(token)
|| token.startsWith("Axis0") || token.startsWith("Axis0")
|| token.startsWith("Axis1"); || 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 { function isLayerModifier(token: string): boolean {
return token === "Button4" || token === "Button6"; return token === "Button4" || token === "Button6";
} }
@@ -114,14 +128,17 @@ function normalizedAxis(value: number): number {
} }
export function setExternalControllerMovement(next: ControllerMovement): void { export function setExternalControllerMovement(next: ControllerMovement): void {
const normalized = { const moveX = normalizedAxis(next.moveX);
moveX: normalizedAxis(next.moveX), const moveY = normalizedAxis(next.moveY);
moveY: normalizedAxis(next.moveY), const lookX = normalizedAxis(next.lookX);
lookX: normalizedAxis(next.lookX), const lookY = normalizedAxis(next.lookY);
lookY: normalizedAxis(next.lookY), if (
}; moveX === movement.moveX
if (Object.keys(normalized).every((key) => normalized[key as keyof ControllerMovement] === movement[key as keyof ControllerMovement])) return; && moveY === movement.moveY
movement = normalized; && lookX === movement.lookX
&& lookY === movement.lookY
) return;
movement = { moveX, moveY, lookX, lookY };
for (const listener of movementListeners) listener(movement); for (const listener of movementListeners) listener(movement);
} }
@@ -141,6 +158,8 @@ export function resetControllerState(): void {
export function startControllerInput(): () => void { export function startControllerInput(): () => void {
if (stopService) return stopService; if (stopService) return stopService;
const webGamepadPolling = !Capacitor.isNativePlatform();
const onNativeToken = (event: Event) => { const onNativeToken = (event: Event) => {
const detail = (event as CustomEvent<ControllerTokenEvent>).detail; const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
if (!detail?.token) return; if (!detail?.token) return;
@@ -157,25 +176,63 @@ export function startControllerInput(): () => void {
if (detail) setExternalControllerMovement(detail); if (detail) setExternalControllerMovement(detail);
}; };
const onReset = () => resetControllerState(); const onReset = () => resetControllerState();
const onVisibility = () => {
if (document.visibilityState !== "visible") resetControllerState();
};
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken); window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion); window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
window.addEventListener(NATIVE_RESET_EVENT, onReset); window.addEventListener(NATIVE_RESET_EVENT, onReset);
document.addEventListener("visibilitychange", onVisibility);
let frame = 0; let frame = 0;
let discoveryTimer: ReturnType<typeof setTimeout> | null = null;
let hadGamepad = false; 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 poll = (now: number) => {
const gamepad = typeof document.hasFocus === "function" && !document.hasFocus() frame = 0;
? null if (!canPollGamepads()) {
: connectedGamepad(); suspendPolling();
return;
}
const gamepad = connectedGamepad();
if (!gamepad) { if (!gamepad) {
if (hadGamepad) resetControllerState(); if (hadGamepad) resetControllerState();
hadGamepad = false; hadGamepad = false;
activeGamepadIndex = null;
scheduleDiscovery();
} else { } else {
hadGamepad = true; hadGamepad = true;
activeGamepadIndex = gamepad.index;
setExternalControllerMovement({ setExternalControllerMovement({
moveX: gamepad.axes[0] ?? 0, moveX: gamepad.axes[0] ?? 0,
moveY: gamepad.axes[1] ?? 0, moveY: gamepad.axes[1] ?? 0,
@@ -183,9 +240,11 @@ export function startControllerInput(): () => void {
lookY: gamepad.axes[3] ?? 0, lookY: gamepad.axes[3] ?? 0,
}); });
tokensFor(gamepad, currentTokens); tokensFor(gamepad, currentTokens);
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) { if (!tokenSetsMatch(previousTokens, currentTokens)) {
emitControllerToken(transition); for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS); emitControllerToken(transition);
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
}
} }
for (const token of currentTokens) { for (const token of currentTokens) {
if (!previousTokens.has(token) || !canRepeat(token)) continue; if (!previousTokens.has(token) || !canRepeat(token)) continue;
@@ -199,17 +258,51 @@ export function startControllerInput(): () => void {
const scratch = previousTokens; const scratch = previousTokens;
previousTokens = currentTokens; previousTokens = currentTokens;
currentTokens = scratch; 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 = () => { stopService = () => {
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken); window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion); window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
window.removeEventListener(NATIVE_RESET_EVENT, onReset); window.removeEventListener(NATIVE_RESET_EVENT, onReset);
document.removeEventListener("visibilitychange", onVisibility); 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(); resetControllerState();
stopService = null; 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 { describe, expect, it } from "vitest";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath"; import {
dprForPerformanceFactor,
performanceBoundsForFrameRate,
} from "./adaptiveGraphicsQualityMath";
describe("adaptive graphics quality", () => { describe("adaptive graphics quality", () => {
it("maps the performance range onto the supported pixel ratio", () => { 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(-1)).toBe(1);
expect(dprForPerformanceFactor(2)).toBe(1.5); 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 { PerformanceMonitor } from "@react-three/drei";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath"; import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
import {
dprForPerformanceFactor,
performanceBoundsForFrameRate,
} from "./adaptiveGraphicsQualityMath";
export function AdaptiveGraphicsQuality({ export function AdaptiveGraphicsQuality({
frameRateLimit,
onDprChange, onDprChange,
}: { }: {
readonly frameRateLimit: GameplayFrameRateLimit;
readonly onDprChange: (dpr: number) => void; readonly onDprChange: (dpr: number) => void;
}) { }) {
return ( return (
@@ -13,7 +19,7 @@ export function AdaptiveGraphicsQuality({
ms={250} ms={250}
step={0.25} step={0.25}
threshold={0.75} threshold={0.75}
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]} bounds={(refreshRate) => performanceBoundsForFrameRate(frameRateLimit, refreshRate)}
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))} 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 { useFrame, useThree } from "@react-three/fiber";
import { import {
AdditiveBlending, AdditiveBlending,
Color,
DoubleSide, DoubleSide,
Group, Group,
MeshBasicMaterial, MeshBasicMaterial,
@@ -19,34 +18,32 @@ import {
type CombatVfxQuality, type CombatVfxQuality,
} from "../game/combatPresentation"; } from "../game/combatPresentation";
import { import {
combatAudioOutputEnabled,
configureCombatAudio, configureCombatAudio,
playCombatAudio, playCombatAudio,
resetCombatAudio, resetCombatAudio,
setCombatAudioListener, setCombatAudioListener,
stopCombatAudioGroup, stopCombatAudioGroup,
} from "../game/combatAudio"; } from "../game/combatAudio";
import {
interface ActiveCombatEffect { advanceCombatEffectFrame,
readonly key: string; capCombatEffects,
readonly event: CombatPresentationEvent; updateCombatEffectQueue,
} type ActiveCombatEffect,
} from "./combatEffectRuntime";
export interface CombatEffectsProps { export interface CombatEffectsProps {
readonly active: boolean; readonly active: boolean;
readonly safeGraphics?: 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>> = { const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
low: 48, low: 48,
high: 128, high: 128,
}; };
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
function effectDurationMs(event: CombatPresentationEvent): number { function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900)); if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320; if (event.phase === "tick") return 320;
@@ -77,7 +74,7 @@ function deterministicParticles(seed: number, count: number): Float32Array {
return result; return result;
} }
function CombatEffectPrimitive({ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
event, event,
quality, quality,
active, active,
@@ -119,15 +116,16 @@ function CombatEffectPrimitive({
useFrame((_state, delta) => { useFrame((_state, delta) => {
if (!active || doneRef.current || !groupRef.current) return; if (!active || doneRef.current || !groupRef.current) return;
elapsedRef.current += Math.min(0.05, delta) * 1_000; const frame = advanceCombatEffectFrame(elapsedRef.current, delta, durationMs);
const progress = Math.min(1, elapsedRef.current / durationMs); elapsedRef.current = frame.elapsedMs;
const progress = frame.progress;
const eased = 1 - (1 - progress) ** 3; const eased = 1 - (1 - progress) ** 3;
if (projectile) { if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased); groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28; 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); groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
} else { } 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" const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08 ? 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); : 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 (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72; if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55; if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
if (progress >= 1) { if (frame.done) {
doneRef.current = true; doneRef.current = true;
onDone(); onDone();
} }
@@ -234,12 +232,13 @@ function CombatEffectPrimitive({
</mesh> </mesh>
</> </>
)} )}
{quality === "high" && event.school !== "physical" ? (
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
) : null}
</group> </group>
); );
} }, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
&& previous.active === next.active
));
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) { export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings); const settings = useShellStore((state) => state.combatPresentationSettings);
@@ -253,6 +252,21 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
const qualityRef = useRef(effectiveQuality); const qualityRef = useRef(effectiveQuality);
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const forward = useMemo(() => new Vector3(), []); 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(() => { useEffect(() => {
settingsRef.current = settings; settingsRef.current = settings;
@@ -260,11 +274,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
configureCombatAudio(settings, masterVolume); configureCombatAudio(settings, masterVolume);
if ( if (
qualityRef.current !== "off" qualityRef.current !== "off"
|| Object.values(settings.audioEnabled).some(Boolean) || combatAudioOutputEnabled(settings, masterVolume)
) void preloadCombatEffectManifest(); ) void preloadCombatEffectManifest();
setEffects((current) => current.filter(({ event }) => ( setEffects((current) => capCombatEffects(current.filter(({ event }) => (
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup] qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
))); )), qualityRef.current));
for (const group of ["player", "party", "enemy"] as const) { for (const group of ["player", "party", "enemy"] as const) {
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group); if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
} }
@@ -298,14 +312,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|| quality === "off" || quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup] || !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast; ) return withoutCast;
const limit = QUALITY_SYSTEM_LIMIT[quality]; return updateCombatEffectQueue(withoutCast, event, quality);
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
}); });
}), []); }), []);
useEffect(() => () => resetCombatAudio(), []); useEffect(() => () => resetCombatAudio(), []);
useFrame(() => { useFrame(() => {
if (!active || !audioOutputEnabled) return;
camera.getWorldDirection(forward); camera.getWorldDirection(forward);
setCombatAudioListener( setCombatAudioListener(
[camera.position.x, camera.position.y, camera.position.z], [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 { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe"; import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality"; 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() { export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -95,8 +99,10 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused); const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen); const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen); const companionOpen = useGameStore((state) => state.companionOpen);
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null); const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true); const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
const documentVisible = useDocumentVisibility();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null); const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false); const [safeGraphics, setSafeGraphics] = useState(false);
@@ -106,6 +112,7 @@ export function GameScene() {
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision); const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null const simulationBlocked = graphicsRecovery !== null
|| !worldReady || !worldReady
|| (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen)); || (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({ const frameloop = sceneFrameLoop({
worldReady, worldReady,
@@ -114,7 +121,10 @@ export function GameScene() {
paused, paused,
mapOpen, mapOpen,
companionOpen, companionOpen,
documentVisible,
frameRateLimit: gameplayFrameRate,
}); });
const targetFps = numericFrameRateLimit(gameplayFrameRate);
const allowedRuntimeIds = useMemo( const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter () => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase) ? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -204,6 +214,8 @@ export function GameScene() {
onLostPointerCapture={endMouseLook} onLostPointerCapture={endMouseLook}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
aria-label="Healer Man 3D dungeon view" aria-label="Healer Man 3D dungeon view"
data-frame-rate-limit={gameplayFrameRate}
data-frame-loop={frameloop}
> >
<Canvas <Canvas
key={rendererCanvasKey(canvasRevision)} key={rendererCanvasKey(canvasRevision)}
@@ -226,8 +238,19 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance", powerPreference: safeGraphics ? "default" : "high-performance",
}} }}
> >
{frameloop === "never" && targetFps !== null && (
<CappedFrameLoop
active={documentVisible || sharedOnline}
targetFps={targetFps}
/>
)}
<GameGltfLoaderLifecycle> <GameGltfLoaderLifecycle>
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />} {!safeGraphics && worldReady && (
<AdaptiveGraphicsQuality
frameRateLimit={gameplayFrameRate}
onDprChange={setAdaptiveDpr}
/>
)}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />} {import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} /> <color attach="background" args={[dungeon.presentation.background]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} /> <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 { export function dprForPerformanceFactor(factor: number): number {
return 1 + Math.max(0, Math.min(1, factor)) * 0.5; 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, paused: false,
mapOpen: false, mapOpen: false,
companionOpen: false, companionOpen: false,
documentVisible: true,
frameRateLimit: 60 as const,
}; };
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand"); expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand"); expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand"); expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always"); expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("never");
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always"); expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("never");
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always"); expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("never");
expect(sceneFrameLoop(readyWorld)).toBe("always"); 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 { DungeonId } from "../game/dungeonRegistry";
import type { Frameloop } from "@react-three/fiber"; import type { Frameloop } from "@react-three/fiber";
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
/** /**
* Encounter transitions must never remount the Canvas: R3F intentionally loses * Encounter transitions must never remount the Canvas: R3F intentionally loses
@@ -41,10 +42,14 @@ export function sceneFrameLoop(options: {
readonly paused: boolean; readonly paused: boolean;
readonly mapOpen: boolean; readonly mapOpen: boolean;
readonly companionOpen: boolean; readonly companionOpen: boolean;
readonly documentVisible: boolean;
readonly frameRateLimit: GameplayFrameRateLimit;
}): Frameloop { }): Frameloop {
if (!options.documentVisible && !options.sharedOnline) return "demand";
const locallyPaused = options.worldReady const locallyPaused = options.worldReady
&& !options.graphicsRecovering && !options.graphicsRecovering
&& !options.sharedOnline && !options.sharedOnline
&& (options.paused || options.mapOpen || options.companionOpen); && (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-align: right;
text-shadow: 0 1px 2px #000, 0 0 3px #000; 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 { min-height: 16px; margin-top: 1px; }
.timed-effect-strip--party .timed-effect { width: 16px; height: 16px; } .timed-effect-strip--party .timed-effect { width: 16px; height: 16px; }
.timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; } .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__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__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__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--target { justify-content: center; margin-top: 3px; }
.aura-strip--party { min-height: 16px; margin-top: 1px; } .aura-strip--party { min-height: 16px; margin-top: 1px; }
.aura-strip--party .aura { width: 16px; height: 16px; } .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 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; } .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; position: absolute;
top: max(10px, env(safe-area-inset-top)); top: max(10px, env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right)); right: max(12px, env(safe-area-inset-right));
display: grid;
width: 154px; 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; text-align: center;
pointer-events: auto; 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 strong { font: 600 11px Georgia, serif; }
.hud-minimap__caption span { margin-top: 2px; color: var(--muted); font-size: 7px; } .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 { .hud-bottom-stack {
position: absolute; position: absolute;
bottom: max(22px, calc(env(safe-area-inset-bottom) + 16px)); 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 { 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__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); } .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; } .player-frame { transform-origin: top left; }
.hud-minimap { transform-origin: top right; }
.target-frame { transform-origin: top center; } .target-frame { transform-origin: top center; }
.action-slot__icon img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; } .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; } .player-frame__portrait { height: 48px; }
.party-frames { top: max(91px, calc(env(safe-area-inset-top) + 79px)); width: 194px; } .party-frames { top: max(91px, calc(env(safe-area-inset-top) + 79px)); width: 194px; }
.party-frame--player { display: none; } .party-frame--player { display: none; }
.hud-minimap { width: 124px; } .hud-top-right { width: 124px; }
.hud-minimap__map { width: 108px; height: 108px; } .hud-minimap__map { width: 108px; height: 108px; }
.spellbook-panel__layout { grid-template-columns: 1fr; } .spellbook-panel__layout { grid-template-columns: 1fr; }
.spellbook-panel__details { display: none; } .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 { min-height: 34px; grid-template-columns: 25px minmax(0,1fr); padding: 3px 4px; }
.party-frame__sigil { width: 23px; height: 23px; } .party-frame__sigil { width: 23px; height: 23px; }
.party-frame__identity small { display: none; } .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__map { width: 86px; height: 86px; }
.hud-minimap__caption { display: none; } .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 > .action-bar { width: min(360px, calc(100vw - 18px)); }
.hud-bottom-stack > .control-hint { max-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); } .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) { @media (max-height: 560px) {
.player-frame { top: 7px; left: 7px; transform: scale(.88); transform-origin: top left; } .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 > .action-bar { width: min(360px, calc(100vw - 18px)); }
.hud-bottom-stack > .control-hint { max-width: min(440px, 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)); } .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 { describe, expect, it } from "vitest";
import type { AuraDefinition } from "../game/combatAuras"; import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip"; import {
LONG_TERM_AURA_DURATION_MS,
auraDisplayName,
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsLongTerm,
auraDescription,
} from "./AuraStrip";
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition { function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
return { 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", () => { describe("aura strip visibility", () => {
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => { it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true); expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
@@ -24,4 +47,28 @@ describe("aura strip visibility", () => {
); );
expect(auraDefinitionIcon(aura())).toBeNull(); 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 { 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 { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay"; import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
export const LONG_TERM_AURA_DURATION_MS = 60_000;
interface AuraStripProps { interface AuraStripProps {
readonly targetId: string; readonly targetId: string;
readonly targetIds?: readonly string[];
readonly dispositions?: readonly AuraDisposition[]; readonly dispositions?: readonly AuraDisposition[];
readonly className?: string; 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 { export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
@@ -17,17 +26,55 @@ export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || 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({ export function AuraStrip({
targetId, targetId,
targetIds,
dispositions = ["buff", "debuff"], dispositions = ["buff", "debuff"],
className = "", className = "",
hideLongTerm = false,
showOnlyLongTerm = false,
targetLabels,
onInspect,
tooltipId,
}: AuraStripProps) { }: AuraStripProps) {
const allAuras = useCombatStore((state) => state.auras); const allAuras = useCombatStore((state) => state.auras);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const requestedTargetIds = targetIds ?? [targetId];
const auras = allAuras.filter((aura) => ( const auras = allAuras.filter((aura) => (
aura.targetId === targetId requestedTargetIds.includes(aura.targetId)
&& dispositions.includes(aura.definition.disposition) && dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition) && auraDefinitionDisplaysInStrip(aura.definition)
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
&& (aura.expiresAt === null || aura.expiresAt > now) && (aura.expiresAt === null || aura.expiresAt > now)
)); ));
@@ -44,14 +91,36 @@ export function AuraStrip({
{auras.map((aura) => { {auras.map((aura) => {
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now); const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining); 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 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 ( return (
<span <span
key={aura.instanceId} key={aura.instanceId}
className={`aura aura--${aura.definition.disposition}`} className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`} tabIndex={onInspect ? 0 : undefined}
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`} 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 {icon
? <img className="aura__icon" src={icon} alt="" draggable={false} /> ? <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 { lazy, Suspense, type CSSProperties } from "react";
import { classById, raceById } from "../app/characterCatalog"; import { classById, raceById } from "../app/characterCatalog";
import type { CharacterDraft, CharacterProfile } from "../app/types"; import type { CharacterDraft, CharacterProfile } from "../app/types";
import { normalizePreviewFrameRateLimit } from "../game/frameRateSettings";
const CharacterPreview3D = lazy(() => import("./CharacterPreview3D")); const CharacterPreview3D = lazy(() => import("./CharacterPreview3D"));
@@ -33,6 +34,9 @@ export function CharacterPreview({ character, draft, compact = false }: {
const characterClass = classById(source.classId); const characterClass = classById(source.classId);
const name = "name" in source && source.name ? source.name : "New adventurer"; const name = "name" in source && source.name ? source.name : "New adventurer";
const rotation = "rotation" in source ? source.rotation : -8; const rotation = "rotation" in source ? source.rotation : -8;
const previewFrameRate = normalizePreviewFrameRateLimit(
"settings" in source ? source.settings?.previewFrameRate : undefined,
);
const style = { const style = {
"--race-accent": race.accent, "--race-accent": race.accent,
"--class-color": characterClass.color, "--class-color": characterClass.color,
@@ -45,7 +49,7 @@ export function CharacterPreview({ character, draft, compact = false }: {
<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} /> <AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />
) : ( ) : (
<Suspense fallback={<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> </Suspense>
)} )}
<span className="sr-only">{source.gender} {race.name} {characterClass.name} preview</span> <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 { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar"; import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { classById, raceById } from "../app/characterCatalog"; 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({ function FittedCharacter({
characterClassColor, characterClassColor,
@@ -30,19 +36,34 @@ function FittedCharacter({
); );
} }
export default function CharacterPreview3D({ source }: { export default function CharacterPreview3D({ frameRateLimit, source }: {
frameRateLimit: PreviewFrameRateLimit;
source: CharacterProfile | CharacterDraft; source: CharacterProfile | CharacterDraft;
}) { }) {
const race = raceById(source.raceId); const race = raceById(source.raceId);
const characterClass = classById(source.classId); const characterClass = classById(source.classId);
const turnDegrees = "rotation" in source ? source.rotation : -8; const turnDegrees = "rotation" in source ? source.rotation : -8;
const documentVisible = useDocumentVisibility();
const targetFps = numericFrameRateLimit(frameRateLimit);
const frameloop = !documentVisible
? "demand"
: targetFps === null ? "always" : "never";
return ( 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 <Canvas
dpr={[1, 1.25]} dpr={[1, 1.25]}
frameloop={frameloop}
camera={{ fov: 32, near: 0.05, far: 40, position: [3.2, 1.8, 4.6] }} 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" /> <ambientLight intensity={1.15} color="#d8e2ca" />
<hemisphereLight args={["#d8e9ce", "#142019", 1.8]} /> <hemisphereLight args={["#d8e9ce", "#142019", 1.8]} />
<directionalLight position={[3, 5, 4]} intensity={2.1} color="#f0e5bf" /> <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 { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { ExperienceBar } from "./ExperienceBar"; import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap"; import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { PlayerFrame } from "./PlayerFrame"; import { PlayerFrame } from "./PlayerFrame";
import { PartyFrames } from "./PartyFrames"; import { PartyFrames } from "./PartyFrames";
import { TimedEffectStrip } from "./TimedEffectStrip"; import { TimedEffectStrip } from "./TimedEffectStrip";
@@ -344,14 +345,17 @@ export function Hud() {
</section> </section>
) : null} ) : null}
<Minimap <div className="hud-top-right">
position={playerPosition} <Minimap
yaw={cameraYaw} position={playerPosition}
areaName={stagePresentation.areaName} yaw={cameraYaw}
distanceTravelled={distance} areaName={stagePresentation.areaName}
rotationMode={settings.minimapRotation} distanceTravelled={distance}
onOpenMap={toggleMap} rotationMode={settings.minimapRotation}
/> onOpenMap={toggleMap}
/>
<PlayerStatusPanel now={clock} />
</div>
{target && settings.showTargetFrame && ( {target && settings.showTargetFrame && (
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}> <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 CombatPresentationSettings,
type CombatVfxQuality, type CombatVfxQuality,
} from "../game/combatPresentation"; } from "../game/combatPresentation";
import {
GAMEPLAY_FRAME_RATE_LIMITS,
PREVIEW_FRAME_RATE_LIMITS,
frameRateLimitLabel,
nextFrameRateLimit,
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "../game/frameRateSettings";
export interface OptionsPanelSettings { export interface OptionsPanelSettings {
showMobHealthBars: boolean; showMobHealthBars: boolean;
@@ -24,6 +32,8 @@ export interface OptionsPanelSettings {
masterVolume: number; masterVolume: number;
uiScale: number; uiScale: number;
minimapRotation: "north-up" | "player-up"; minimapRotation: "north-up" | "player-up";
gameplayFrameRate: GameplayFrameRateLimit;
previewFrameRate: PreviewFrameRateLimit;
} }
export interface OptionsPanelProps { export interface OptionsPanelProps {
@@ -87,7 +97,15 @@ function OptionsDialog({
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality); const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] }); 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[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
...TOGGLES.map((option) => ({ ...TOGGLES.map((option) => ({
id: `option-${option.id}`, id: `option-${option.id}`,
run: () => onChange({ [option.id]: !settings[option.id] }), run: () => onChange({ [option.id]: !settings[option.id] }),
@@ -141,6 +159,24 @@ function OptionsDialog({
</header> </header>
<div className="options-panel__list"> <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) => ( {TOGGLES.map((option) => (
<div key={option.id} className="option-row"> <div key={option.id} className="option-row">
<span><strong>{option.label}</strong><small>{option.description}</small></span> <span><strong>{option.label}</strong><small>{option.description}</small></span>
@@ -185,7 +221,7 @@ function OptionsDialog({
</div> </div>
<h3 className="options-panel__section-title">Combat presentation</h3> <h3 className="options-panel__section-title">Combat presentation</h3>
<div className="option-row"> <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)} {optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
</div> </div>
{COMBAT_ACTOR_GROUPS.map((group) => ( {COMBAT_ACTOR_GROUPS.map((group) => (
+7 -3
View File
@@ -149,7 +149,7 @@ function MemberFrame({ member, selected, onSelect }: {
<i style={{ width: `${resourcePercent}%` }} /> <i style={{ width: `${resourcePercent}%` }} />
</span> </span>
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" /> <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> </span>
</button> </button>
); );
@@ -273,8 +273,12 @@ export function PartyFrames() {
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}> <span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} /> <i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
</span> </span>
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" /> <TimedEffectStrip
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" /> targetId={null}
targetIds={[null, PLAYER_AGGRO_ID]}
className="timed-effect-strip--party"
/>
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
</span> </span>
</button> </button>
{onlineMembers.map((member) => { {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, formatTimedEffectTimer,
timedEffectRemainingMs, timedEffectRemainingMs,
} from "../game/timedEffectDisplay"; } from "../game/timedEffectDisplay";
import {
timedEffectDescription,
type StatusEffectInspectHandler,
type StatusEffectInspection,
} from "./statusEffectInspection";
export type TimedEffectKind = TimedCombatEffect["kind"]; export type TimedEffectKind = TimedCombatEffect["kind"];
interface TimedEffectStripProps { interface TimedEffectStripProps {
readonly targetId: string | null; readonly targetId: string | null;
readonly targetIds?: readonly (string | null)[];
readonly kinds?: readonly TimedEffectKind[]; readonly kinds?: readonly TimedEffectKind[];
readonly className?: string; readonly className?: string;
readonly onInspect?: StatusEffectInspectHandler;
readonly tooltipId?: string;
} }
function LiveTimedEffectStrip({ function LiveTimedEffectStrip({
effects, effects,
className = "", className = "",
onInspect,
tooltipId,
}: { }: {
readonly effects: readonly TimedCombatEffect[]; readonly effects: readonly TimedCombatEffect[];
readonly className?: string; readonly className?: string;
readonly onInspect?: StatusEffectInspectHandler;
readonly tooltipId?: string;
}) { }) {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -45,12 +57,29 @@ function LiveTimedEffectStrip({
{visibleEffects.map(({ effect, ability, remainingMs }) => { {visibleEffects.map(({ effect, ability, remainingMs }) => {
const label = ability?.name ?? effect.sourceAbilityId; const label = ability?.name ?? effect.sourceAbilityId;
const timer = formatTimedEffectTimer(remainingMs); 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 ( return (
<span <span
key={effect.id} 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`} 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 {ability?.icon
? <img src={ability.icon} alt="" draggable={false} /> ? <img src={ability.icon} alt="" draggable={false} />
@@ -69,14 +98,25 @@ function LiveTimedEffectStrip({
*/ */
export function TimedEffectStrip({ export function TimedEffectStrip({
targetId, targetId,
targetIds,
kinds = ["dot", "hot"], kinds = ["dot", "hot"],
className, className,
onInspect,
tooltipId,
}: TimedEffectStripProps) { }: TimedEffectStripProps) {
const allEffects = useCombatStore((state) => state.effects); const allEffects = useCombatStore((state) => state.effects);
const requestedTargetIds = targetIds ?? [targetId];
const effects = allEffects.filter((effect) => ( 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; 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.";
}