forked from phenom/healer-man
Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5864
-297
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
TypeScript production build completed successfully with 983 modules
|
||||
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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.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",
|
||||
"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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.8",
|
||||
"versionCode": 1008
|
||||
"versionName": "0.1.9",
|
||||
"versionCode": 1009
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -61,10 +61,13 @@ import {
|
||||
import {
|
||||
EquipmentAttachments,
|
||||
equipmentAttachmentHand,
|
||||
resolveEquipmentAttachmentTransforms,
|
||||
} from "./equipmentAttachments";
|
||||
import {
|
||||
ApproximateStarterEquipment,
|
||||
StarterWeaponAttachments,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
type AvatarHand,
|
||||
} from "./starterWeapons";
|
||||
|
||||
@@ -192,6 +195,14 @@ function LoadedCharacterModel({
|
||||
}) {
|
||||
const gltf = useGLTF(resolveContentUrl(entry.model));
|
||||
const scene = useMemo(() => cloneAvatarScene(gltf.scene), [gltf.scene]);
|
||||
const equipmentAttachmentTransforms = useMemo(
|
||||
() => resolveEquipmentAttachmentTransforms(scene, entry),
|
||||
[entry, scene],
|
||||
);
|
||||
const fallbackEquipmentScale = useMemo(
|
||||
() => estimateAvatarWeaponScale(scene),
|
||||
[scene],
|
||||
);
|
||||
const mixer = useMemo(() => new AnimationMixer(scene), [scene]);
|
||||
const currentAction = useRef<AnimationAction | null>(null);
|
||||
const overrideAction = useRef<AnimationAction | null>(null);
|
||||
@@ -199,7 +210,6 @@ function LoadedCharacterModel({
|
||||
const overrideLooping = useRef(false);
|
||||
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
|
||||
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
||||
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
||||
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
||||
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
|
||||
const attackJoint = useMemo(
|
||||
@@ -233,6 +243,15 @@ function LoadedCharacterModel({
|
||||
}))] as AvatarHand[],
|
||||
[resolved.equipmentModels],
|
||||
);
|
||||
const weaponStance = useMemo(() => characterWeaponStance(
|
||||
identity.classId,
|
||||
equipment,
|
||||
resolved.equipmentModels.flatMap((itemModel) => (
|
||||
itemModel.attachment === "handLeft" || itemModel.attachment === "handRight"
|
||||
? [itemModel.itemId]
|
||||
: []
|
||||
)),
|
||||
), [equipment, identity.classId, resolved.equipmentModels]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -339,7 +358,7 @@ function LoadedCharacterModel({
|
||||
};
|
||||
|
||||
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
|
||||
selectCharacterBaseClip(gltf.animations, motion)
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0]
|
||||
@@ -347,7 +366,6 @@ function LoadedCharacterModel({
|
||||
|
||||
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||
if (overrideAction.current) return;
|
||||
if (currentMotion.current === motion) return;
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
: null;
|
||||
@@ -356,6 +374,7 @@ function LoadedCharacterModel({
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
if (!clip) return;
|
||||
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
const next = mixer.clipAction(clip, scene);
|
||||
next.enabled = true;
|
||||
@@ -462,15 +481,11 @@ function LoadedCharacterModel({
|
||||
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
||||
observedAnimationRevision.current = nextAnimationEvent.revision;
|
||||
playAnimationEvent(nextAnimationEvent);
|
||||
if (nextAnimationEvent.kind === "attack" || nextAnimationEvent.kind === "ability-release") {
|
||||
equipmentAttackPulseRef.current = nextAnimationEvent.revision;
|
||||
}
|
||||
}
|
||||
const nextAttackPulse = attackPulseRef?.current ?? 0;
|
||||
if (nextAttackPulse !== observedAttackPulse.current) {
|
||||
observedAttackPulse.current = nextAttackPulse;
|
||||
if (nextAttackPulse > 0) {
|
||||
equipmentAttackPulseRef.current = nextAttackPulse;
|
||||
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
||||
}
|
||||
}
|
||||
@@ -513,17 +528,16 @@ function LoadedCharacterModel({
|
||||
/>
|
||||
</group>
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
occupiedHands={occupiedEquipmentHands}
|
||||
root={scene}
|
||||
/>
|
||||
<EquipmentAttachments
|
||||
attackPulseRef={equipmentAttackPulseRef}
|
||||
classId={identity.classId}
|
||||
entry={entry}
|
||||
fallbackScale={fallbackEquipmentScale}
|
||||
models={resolved.equipmentModels}
|
||||
root={scene}
|
||||
transforms={equipmentAttachmentTransforms}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Bone,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
Skeleton,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findAvatarSkinBone,
|
||||
resolveAvatarAttachmentPoint,
|
||||
} from "./attachmentPoints";
|
||||
|
||||
const expectVectorClose = (actual: Vector3, expected: Vector3): void => {
|
||||
expect(actual.x).toBeCloseTo(expected.x, 6);
|
||||
expect(actual.y).toBeCloseTo(expected.y, 6);
|
||||
expect(actual.z).toBeCloseTo(expected.z, 6);
|
||||
};
|
||||
|
||||
function attachmentRig() {
|
||||
const world = new Group();
|
||||
world.position.set(4, -2, 7);
|
||||
world.rotation.set(0.2, -0.35, 0.1);
|
||||
world.scale.setScalar(1.7);
|
||||
|
||||
const root = new Group();
|
||||
root.position.set(-1, 3, 2);
|
||||
root.rotation.set(-0.1, 0.25, -0.15);
|
||||
root.scale.setScalar(0.8);
|
||||
world.add(root);
|
||||
|
||||
const internalRig = new Group();
|
||||
internalRig.scale.setScalar(0.1);
|
||||
root.add(internalRig);
|
||||
const unusedBone = new Bone();
|
||||
const attachmentBone = new Bone();
|
||||
attachmentBone.position.set(2, 4, -3);
|
||||
internalRig.add(unusedBone, attachmentBone);
|
||||
|
||||
const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial());
|
||||
mesh.bind(new Skeleton([unusedBone, attachmentBone]));
|
||||
root.add(mesh);
|
||||
|
||||
return { attachmentBone, root, world };
|
||||
}
|
||||
|
||||
describe("avatar attachment-point transforms", () => {
|
||||
it("converts manifest positions into the selected skin bone", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const point = { bone: 1, position: [0.45, 0.9, -0.2] as const };
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, point, null);
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(findAvatarSkinBone(root, 1)).toBe(attachmentBone);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
const expectedWorldPosition = root.localToWorld(new Vector3(...point.position));
|
||||
const actualWorldPosition = attachmentBone.localToWorld(transform!.position.clone());
|
||||
expectVectorClose(actualWorldPosition, expectedWorldPosition);
|
||||
});
|
||||
|
||||
it("cancels internal locator scale without cancelling the avatar scale", () => {
|
||||
const { attachmentBone, root, world } = attachmentRig();
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, attachmentBone)!;
|
||||
const portalChild = new Group();
|
||||
portalChild.scale.copy(transform.scale);
|
||||
attachmentBone.add(portalChild);
|
||||
|
||||
world.updateWorldMatrix(true, true);
|
||||
expectVectorClose(
|
||||
portalChild.getWorldScale(new Vector3()),
|
||||
root.getWorldScale(new Vector3()),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authored hand joints at local zero after the skeleton animates", () => {
|
||||
const { attachmentBone, root } = attachmentRig();
|
||||
attachmentBone.position.set(-6, 8, 4);
|
||||
attachmentBone.rotation.set(0.7, -0.4, 0.2);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 1, position: [0.45, 0.9, -0.2] },
|
||||
null,
|
||||
{ useTargetOrigin: true },
|
||||
);
|
||||
|
||||
expect(transform?.target).toBe(attachmentBone);
|
||||
expect(transform?.position.toArray()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("falls back safely when a manifest joint is unavailable", () => {
|
||||
const { root } = attachmentRig();
|
||||
const fallback = new Group();
|
||||
root.add(fallback);
|
||||
|
||||
const withMissingJoint = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
{ bone: 99, position: [0.1, 0.2, 0.3] },
|
||||
fallback,
|
||||
);
|
||||
expect(withMissingJoint?.target).toBe(fallback);
|
||||
|
||||
const withoutMetadata = resolveAvatarAttachmentPoint(root, undefined, fallback);
|
||||
expect(withoutMetadata?.target).toBe(fallback);
|
||||
expect(withoutMetadata?.position.toArray()).toEqual([0, 0, 0]);
|
||||
expect(resolveAvatarAttachmentPoint(root, undefined, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SkinnedMesh, Vector3, type Object3D } from "three";
|
||||
import type { AvatarAttachmentPoint } from "./characterManifest";
|
||||
|
||||
export interface AvatarAttachmentTransform {
|
||||
readonly target: Object3D;
|
||||
readonly position: Vector3;
|
||||
readonly scale: Vector3;
|
||||
}
|
||||
|
||||
export interface AvatarAttachmentOptions {
|
||||
/** The manifest point is the authored origin of its referenced attachment joint. */
|
||||
readonly useTargetOrigin?: boolean;
|
||||
}
|
||||
|
||||
const safeScaleRatio = (rootScale: number, targetScale: number): number => (
|
||||
Number.isFinite(rootScale)
|
||||
&& Number.isFinite(targetScale)
|
||||
&& Math.abs(targetScale) > 1e-6
|
||||
? rootScale / targetScale
|
||||
: 1
|
||||
);
|
||||
|
||||
function attachmentScale(root: Object3D, target: Object3D): Vector3 {
|
||||
const rootScale = root.getWorldScale(new Vector3());
|
||||
const targetScale = target.getWorldScale(new Vector3());
|
||||
return new Vector3(
|
||||
safeScaleRatio(rootScale.x, targetScale.x),
|
||||
safeScaleRatio(rootScale.y, targetScale.y),
|
||||
safeScaleRatio(rootScale.z, targetScale.z),
|
||||
);
|
||||
}
|
||||
|
||||
export function findAvatarSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
/** Converts a manifest point from avatar-local space into its target bone. */
|
||||
export function resolveAvatarAttachmentPoint(
|
||||
root: Object3D,
|
||||
point: AvatarAttachmentPoint | undefined,
|
||||
fallbackTarget: Object3D | null,
|
||||
options: AvatarAttachmentOptions = {},
|
||||
): AvatarAttachmentTransform | null {
|
||||
const skinBone = point ? findAvatarSkinBone(root, point.bone) : null;
|
||||
const target = skinBone ?? fallbackTarget;
|
||||
if (!target) return null;
|
||||
root.updateWorldMatrix(true, true);
|
||||
const scale = attachmentScale(root, target);
|
||||
if (!point || (skinBone && options.useTargetOrigin)) {
|
||||
return { target, position: new Vector3(), scale };
|
||||
}
|
||||
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position, scale };
|
||||
}
|
||||
@@ -21,6 +21,15 @@ describe("avatar rendering integration invariants", () => {
|
||||
expect(characterModelSource).toContain("<EquipmentAttachments");
|
||||
});
|
||||
|
||||
it("uses manifest sockets while the loaded skeleton owns weapon motion", () => {
|
||||
const loadedStarterAttachment = characterModelSource.match(
|
||||
/<StarterWeaponAttachments[\s\S]*?\/>/,
|
||||
)?.[0] ?? "";
|
||||
expect(loadedStarterAttachment).toContain("entry={entry}");
|
||||
expect(loadedStarterAttachment).not.toContain("attackPulseRef=");
|
||||
expect(characterModelSource).not.toContain("equipmentAttackPulseRef");
|
||||
});
|
||||
|
||||
it("isolates each optional gear model behind its own loading and error boundary", () => {
|
||||
expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary");
|
||||
expect(equipmentAttachmentSource).toContain("<Suspense fallback={fallback}>");
|
||||
|
||||
@@ -35,7 +35,7 @@ const REQUIRED_NATIVE_IDS = [
|
||||
0, 1, 4, 5, 8, 9, 10,
|
||||
16, 17, 18, 19,
|
||||
25, 26, 27, 28,
|
||||
46,
|
||||
46, 109,
|
||||
51, 52, 53, 54,
|
||||
57, 58, 95, 124, 125,
|
||||
] as const;
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("resolveEquipmentAppearance", () => {
|
||||
|
||||
expect(resolved.models).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ itemId: 6472, attachment: "handRight" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "handLeft" }),
|
||||
expect.objectContaining({ itemId: 6447, attachment: "shield" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderLeft" }),
|
||||
expect.objectContaining({ itemId: 5404, attachment: "shoulderRight" }),
|
||||
]));
|
||||
@@ -39,6 +39,26 @@ describe("resolveEquipmentAppearance", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes every shield model through the dedicated shield attachment", () => {
|
||||
for (const itemId of [6447, 6630, 13245]) {
|
||||
expect(resolveEquipmentAppearance([item(itemId)], "male").models).toEqual([
|
||||
expect.objectContaining({ itemId, attachment: "shield" }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a stance-matched procedural fallback for every held model", () => {
|
||||
for (const itemId of [5243, 6447, 6448, 6469, 6472, 6630, 6631, 13245]) {
|
||||
const heldModels = resolveEquipmentAppearance([item(itemId)], "male").models
|
||||
.filter((model) => model.attachment !== "shoulderLeft" && model.attachment !== "shoulderRight");
|
||||
expect(heldModels).not.toHaveLength(0);
|
||||
for (const model of heldModels) {
|
||||
expect(model.fallbackKind).toBeTruthy();
|
||||
expect(model.weaponStance).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gendered robe textures, equipment geosets, and the actual cape skin", () => {
|
||||
const resolved = resolveEquipmentAppearance([
|
||||
item(6465),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GenderId } from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { TextureRegion } from "./characterManifest";
|
||||
|
||||
@@ -33,6 +34,8 @@ export interface EquipmentModelVisual {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly attachment: EquipmentAttachmentName;
|
||||
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
|
||||
readonly weaponStance?: CharacterWeaponStance;
|
||||
}
|
||||
|
||||
interface GenderedTextureLayer {
|
||||
@@ -67,6 +70,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Firebelcher",
|
||||
url: model("firebelcher-5243"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
5404: {
|
||||
@@ -101,7 +106,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Worn Turtle Shell Shield",
|
||||
url: model("worn-turtle-shell-shield-6447"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6448: {
|
||||
@@ -109,6 +116,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Tail Spike",
|
||||
url: model("tail-spike-6448"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "dagger",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6449: {
|
||||
@@ -188,6 +197,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Venomstrike",
|
||||
url: model("venomstrike-6469"),
|
||||
attachment: "handLeft",
|
||||
fallbackKind: "bow",
|
||||
weaponStance: "bow",
|
||||
}],
|
||||
},
|
||||
6472: {
|
||||
@@ -195,6 +206,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Stinging Viper",
|
||||
url: model("stinging-viper-6472"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "mace",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6473: {
|
||||
@@ -237,7 +250,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Seedcloud Buckler",
|
||||
url: model("seedcloud-buckler-6630"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
6631: {
|
||||
@@ -245,6 +260,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
name: "Living Root",
|
||||
url: model("living-root-6631"),
|
||||
attachment: "handRight",
|
||||
fallbackKind: "staff",
|
||||
weaponStance: "two-handed-left",
|
||||
}],
|
||||
},
|
||||
6632: {
|
||||
@@ -291,7 +308,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
models: [{
|
||||
name: "Kresh's Back",
|
||||
url: model("kreshs-back-13245"),
|
||||
attachment: "handLeft",
|
||||
attachment: "shield",
|
||||
fallbackKind: "shield",
|
||||
weaponStance: "one-handed",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,31 +5,47 @@ import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DoubleSide,
|
||||
Mesh,
|
||||
Object3D,
|
||||
SkinnedMesh,
|
||||
Vector3,
|
||||
type Material,
|
||||
} from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import type {
|
||||
EquipmentAttachmentName,
|
||||
EquipmentModelVisual,
|
||||
} from "./equipmentAppearance";
|
||||
import {
|
||||
resolveAvatarAttachmentPoint,
|
||||
type AvatarAttachmentTransform,
|
||||
} from "./attachmentPoints";
|
||||
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
||||
import {
|
||||
avatarWeaponSocketRotation,
|
||||
findAvatarHandBone,
|
||||
StarterWeaponAttachments,
|
||||
findAvatarShieldBone,
|
||||
StarterWeaponVisual,
|
||||
type AvatarHand,
|
||||
type StarterWeaponRotation,
|
||||
} from "./starterWeapons";
|
||||
|
||||
const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.freeze([
|
||||
"handLeft",
|
||||
"handRight",
|
||||
"shield",
|
||||
"shoulderLeft",
|
||||
"shoulderRight",
|
||||
]);
|
||||
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
|
||||
export type EquipmentAttachmentTransforms = Readonly<
|
||||
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
|
||||
>;
|
||||
|
||||
export function equipmentAttachmentHand(
|
||||
attachment: EquipmentAttachmentName,
|
||||
): AvatarHand | null {
|
||||
@@ -58,23 +74,14 @@ function cloneEquipmentScene(source: Object3D, name: string): Object3D {
|
||||
return scene;
|
||||
}
|
||||
|
||||
function findSkinBone(root: Object3D, boneIndex: number): Object3D | null {
|
||||
let bone: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (bone) return;
|
||||
const skinnedMesh = node as SkinnedMesh;
|
||||
if (!skinnedMesh.isSkinnedMesh) return;
|
||||
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
|
||||
});
|
||||
return bone;
|
||||
}
|
||||
|
||||
function fallbackTarget(
|
||||
root: Object3D,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): Object3D | null {
|
||||
if (attachment === "handLeft" || attachment === "shield") {
|
||||
return findAvatarHandBone(root, "left");
|
||||
return attachment === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: findAvatarHandBone(root, "left");
|
||||
}
|
||||
if (attachment === "handRight") return findAvatarHandBone(root, "right");
|
||||
return root.getObjectByName(
|
||||
@@ -82,43 +89,42 @@ function fallbackTarget(
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function attachmentTransform(
|
||||
/** Snapshots all authored attachment offsets once, before animation begins. */
|
||||
export function resolveEquipmentAttachmentTransforms(
|
||||
root: Object3D,
|
||||
entry: AvatarManifestEntry,
|
||||
attachment: EquipmentAttachmentName,
|
||||
): { target: Object3D; position: Vector3 } | null {
|
||||
const point = entry.attachmentPoints?.[attachment];
|
||||
const target = point
|
||||
? findSkinBone(root, point.bone) ?? fallbackTarget(root, attachment)
|
||||
: fallbackTarget(root, attachment);
|
||||
if (!target) return null;
|
||||
if (!point) return { target, position: new Vector3() };
|
||||
|
||||
root.updateWorldMatrix(true, true);
|
||||
const position = root.localToWorld(new Vector3(...point.position));
|
||||
target.worldToLocal(position);
|
||||
return { target, position };
|
||||
): EquipmentAttachmentTransforms {
|
||||
const transforms: Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>> = {};
|
||||
for (const attachment of EQUIPMENT_ATTACHMENT_NAMES) {
|
||||
const transform = resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
entry.attachmentPoints?.[attachment],
|
||||
fallbackTarget(root, attachment),
|
||||
{
|
||||
useTargetOrigin: attachment === "handLeft"
|
||||
|| attachment === "handRight"
|
||||
|| attachment === "shield",
|
||||
},
|
||||
);
|
||||
if (transform) transforms[attachment] = transform;
|
||||
}
|
||||
return transforms;
|
||||
}
|
||||
|
||||
function AttachedEquipmentModel({
|
||||
entry,
|
||||
itemModel,
|
||||
root,
|
||||
transform,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly root: Object3D;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
const gltf = useGLTF(resolveContentUrl(itemModel.url));
|
||||
const scene = useMemo(
|
||||
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
|
||||
[gltf.scene, itemModel.attachment, itemModel.itemId],
|
||||
);
|
||||
const transform = useMemo(
|
||||
() => attachmentTransform(root, entry, itemModel.attachment),
|
||||
[entry, itemModel.attachment, root],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
const materials = new Set<Material>();
|
||||
scene.traverse((node) => {
|
||||
@@ -130,52 +136,109 @@ function AttachedEquipmentModel({
|
||||
materials.forEach((material) => material.dispose());
|
||||
}, [scene]);
|
||||
|
||||
if (!transform) return null;
|
||||
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
|
||||
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
|
||||
: NO_ROTATION;
|
||||
|
||||
return createPortal(
|
||||
<primitive object={scene} position={transform.position} />,
|
||||
<group
|
||||
position={transform.position}
|
||||
rotation={socketRotation}
|
||||
scale={transform.scale}
|
||||
>
|
||||
<primitive object={scene} />
|
||||
</group>,
|
||||
transform.target,
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentAttachments({
|
||||
attackPulseRef,
|
||||
classId,
|
||||
function EquipmentModelFallback({
|
||||
entry,
|
||||
models,
|
||||
root,
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
}: {
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly models: readonly EquipmentModelVisual[];
|
||||
readonly root: Object3D;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
}): ReactNode {
|
||||
return models.map((itemModel) => {
|
||||
const hand = equipmentAttachmentHand(itemModel.attachment);
|
||||
const fallback = hand
|
||||
? (
|
||||
<StarterWeaponAttachments
|
||||
attackPulseRef={attackPulseRef}
|
||||
classId={classId}
|
||||
onlyHands={[hand]}
|
||||
root={root}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
</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
|
||||
key={`${itemModel.itemId}:${itemModel.attachment}`}
|
||||
fallback={fallback}
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel
|
||||
entry={entry}
|
||||
itemModel={itemModel}
|
||||
root={root}
|
||||
/>
|
||||
<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 transforms: EquipmentAttachmentTransforms;
|
||||
}): ReactNode {
|
||||
return models.map((itemModel) => {
|
||||
const transform = transforms[itemModel.attachment];
|
||||
return transform ? (
|
||||
<ResolvedEquipmentAttachment
|
||||
key={`${itemModel.itemId}:${itemModel.attachment}`}
|
||||
entry={entry}
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AvatarManifest } from "./characterManifest";
|
||||
|
||||
interface RunewakerGlbDocument {
|
||||
readonly animations?: readonly { readonly name?: string }[];
|
||||
readonly nodes?: readonly { readonly name?: string }[];
|
||||
}
|
||||
|
||||
function readGlbDocument(glb: Buffer): RunewakerGlbDocument {
|
||||
expect(glb.readUInt32LE(0)).toBe(0x46546c67);
|
||||
const jsonLength = glb.readUInt32LE(12);
|
||||
return JSON.parse(
|
||||
glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd(),
|
||||
) as RunewakerGlbDocument;
|
||||
}
|
||||
|
||||
const manifestFile = path.resolve("public/assets/characters/runewaker-v1/manifest.json");
|
||||
|
||||
describe("runewaker-v1 playable avatar pack", () => {
|
||||
@@ -32,6 +45,17 @@ describe("runewaker-v1 playable avatar pack", () => {
|
||||
});
|
||||
const model = path.resolve("public", entry.model.replace(/^\//, ""));
|
||||
expect((await stat(model)).size).toBe(entry.bytes);
|
||||
const document = readGlbDocument(await readFile(model));
|
||||
const animationNames = new Set(
|
||||
document.animations?.map((animation) => animation.name ?? "") ?? [],
|
||||
);
|
||||
for (const animationName of ["Idle - 1h_idle", "Idle - 2h_idle", "Idle - bow_idle"]) {
|
||||
expect(animationNames.has(animationName), `${entry.model} ${animationName}`).toBe(true);
|
||||
}
|
||||
const nodeNames = new Set(document.nodes?.map((node) => node.name ?? "") ?? []);
|
||||
for (const nodeName of ["p_left_hand", "p_right_hand", "p_hand_shield"]) {
|
||||
expect(nodeNames.has(nodeName), `${entry.model} ${nodeName}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Group, Object3D } from "three";
|
||||
import { Euler, Group, Object3D, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||
import {
|
||||
STARTER_WEAPON_LOADOUTS,
|
||||
approximateStarterWeaponGripRotation,
|
||||
avatarWeaponSocketRotation,
|
||||
characterWeaponStance,
|
||||
estimateAvatarWeaponScale,
|
||||
findAvatarHandBone,
|
||||
findAvatarHandBones,
|
||||
findAvatarShieldBone,
|
||||
sampleStarterWeaponStrike,
|
||||
starterWeaponItemsForAvailableHands,
|
||||
starterWeaponLoadout,
|
||||
starterWeaponModelRotation,
|
||||
starterWeaponStance,
|
||||
} from "./starterWeapons";
|
||||
import type { AvatarManifestEntry } from "./characterManifest";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import { equipmentAttachmentHand } from "./equipmentAttachments";
|
||||
|
||||
interface RawGlbNode {
|
||||
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
|
||||
expect(equipmentAttachmentHand("shield")).toBe("left");
|
||||
expect(equipmentAttachmentHand("shoulderLeft")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the native ready stance that matches each loadout", () => {
|
||||
expect(starterWeaponStance("warrior")).toBe("one-handed");
|
||||
expect(starterWeaponStance("rogue")).toBe("one-handed");
|
||||
expect(starterWeaponStance("hunter")).toBe("bow");
|
||||
expect(starterWeaponStance("priest")).toBe("two-handed-left");
|
||||
expect(starterWeaponStance("death-knight")).toBe("two-handed");
|
||||
expect(starterWeaponStance("rom-priest")).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("lets visible equipped weapons override the class starter stance", () => {
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 6472, weaponType: "dagger" },
|
||||
], [6472])).toBe("one-handed");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "two-hand", itemId: 6631, weaponType: "staff" },
|
||||
], [6631])).toBe("two-handed-left");
|
||||
expect(characterWeaponStance("warrior", [
|
||||
{ inventorySlot: "ranged", itemId: 6469, weaponType: "bow" },
|
||||
], [6469])).toBe("bow");
|
||||
expect(characterWeaponStance("priest", [
|
||||
{ inventorySlot: "one-hand", itemId: 9999, weaponType: "dagger" },
|
||||
], [])).toBe("two-handed-left");
|
||||
});
|
||||
|
||||
it("normalizes procedural equipment to the native WoW item axes", () => {
|
||||
const rotated = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => new Vector3(...direction).applyEuler(new Euler(...starterWeaponModelRotation(kind)));
|
||||
|
||||
expect(rotated("sword", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("dagger", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("mace", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("staff", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(rotated("bow", [0, 0, -1]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(starterWeaponModelRotation("shield")).toEqual([0, 0, 0]);
|
||||
|
||||
const fallbackDirection = (
|
||||
kind: Parameters<typeof starterWeaponModelRotation>[0],
|
||||
direction: readonly [number, number, number],
|
||||
) => rotated(kind, direction).applyEuler(
|
||||
new Euler(...approximateStarterWeaponGripRotation(kind)),
|
||||
);
|
||||
expect(fallbackDirection("sword", [0, -1, 0]).distanceTo(new Vector3(0, -1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("staff", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(fallbackDirection("bow", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it("adapts canonical weapons to RuneWaker prop-socket axes", () => {
|
||||
const entry = {
|
||||
rendererStrategy: { kind: "runewaker-modular" },
|
||||
} as AvatarManifestEntry;
|
||||
const socketDirection = (
|
||||
kind: Parameters<typeof avatarWeaponSocketRotation>[1],
|
||||
stance: Parameters<typeof avatarWeaponSocketRotation>[2],
|
||||
) => new Vector3(1, 0, 0).applyEuler(
|
||||
new Euler(...avatarWeaponSocketRotation(entry, kind, stance)),
|
||||
);
|
||||
|
||||
expect(socketDirection("sword", "one-handed").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("bow", "bow").distanceTo(new Vector3(0, 0, 1)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("staff", "two-handed-left").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("sword", "two-handed").distanceTo(new Vector3(0, 1, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(socketDirection("shield", "one-handed").distanceTo(new Vector3(1, 0, 0)))
|
||||
.toBeLessThan(1e-6);
|
||||
expect(avatarWeaponSocketRotation(undefined, "sword", "one-handed"))
|
||||
.toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatar hand-bone discovery", () => {
|
||||
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
|
||||
expect(findAvatarHandBone(root, "right")).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritizes explicit RuneWaker equipment locators over generic hand bones", () => {
|
||||
const root = new Group();
|
||||
const genericLeft = new Group();
|
||||
genericLeft.name = "Bip01 L Hand";
|
||||
const genericRight = new Group();
|
||||
genericRight.name = "Bip01 R Hand";
|
||||
const leftPivot = new Group();
|
||||
leftPivot.name = "p_left_hand";
|
||||
const rightPivot = new Group();
|
||||
rightPivot.name = "p_right_hand";
|
||||
const shieldPivot = new Group();
|
||||
shieldPivot.name = "p_hand_shield";
|
||||
root.add(genericLeft, genericRight, leftPivot, rightPivot, shieldPivot);
|
||||
|
||||
expect(findAvatarHandBone(root, "left")).toBe(leftPivot);
|
||||
expect(findAvatarHandBone(root, "right")).toBe(rightPivot);
|
||||
expect(findAvatarShieldBone(root)).toBe(shieldPivot);
|
||||
});
|
||||
|
||||
it("cancels inherited locator scale for portal children", () => {
|
||||
const root = new Group();
|
||||
const scaledRig = new Group();
|
||||
scaledRig.scale.setScalar(0.1);
|
||||
const pivot = new Group();
|
||||
root.add(scaledRig);
|
||||
scaledRig.add(pivot);
|
||||
|
||||
const transform = resolveAvatarAttachmentPoint(root, undefined, pivot);
|
||||
expect(transform?.scale.toArray()).toEqual([10, 10, 10]);
|
||||
});
|
||||
|
||||
it("estimates equipment scale in avatar-local space and ignores parent transforms", () => {
|
||||
const actor = new Group();
|
||||
actor.position.set(20, 8, -12);
|
||||
@@ -143,7 +265,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
});
|
||||
|
||||
it("resolves both hands in every shipped race/gender character rig", () => {
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1"].map((version) => path.join(
|
||||
const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join(
|
||||
projectDirectory,
|
||||
"public",
|
||||
"assets",
|
||||
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
|
||||
const entries = manifestPaths.flatMap((manifestPath) => (
|
||||
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
|
||||
));
|
||||
expect(entries).toHaveLength(45);
|
||||
expect(entries).toHaveLength(50);
|
||||
const resolvedLeftNames = new Set<string>();
|
||||
const resolvedRightNames = new Set<string>();
|
||||
const unresolved: string[] = [];
|
||||
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
|
||||
// accidentally hardcodes one id cannot satisfy this diversity check.
|
||||
expect(resolvedLeftNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedRightNames.size).toBeGreaterThan(8);
|
||||
expect(resolvedLeftNames).toContain("p_left_hand");
|
||||
expect(resolvedRightNames).toContain("p_right_hand");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
|
||||
+221
-20
@@ -7,6 +7,10 @@ import {
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
|
||||
import type { AvatarAttachmentPoint, AvatarManifestEntry } from "./characterManifest";
|
||||
|
||||
export type AvatarHand = "left" | "right";
|
||||
|
||||
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
|
||||
export interface StarterWeaponLoadout {
|
||||
readonly classId: ClassId;
|
||||
readonly items: readonly StarterWeaponItem[];
|
||||
readonly stance: StarterWeaponStance;
|
||||
}
|
||||
|
||||
export type StarterWeaponStance = CharacterWeaponStance;
|
||||
|
||||
const item = (
|
||||
id: string,
|
||||
kind: StarterWeaponKind,
|
||||
@@ -45,6 +52,7 @@ const item = (
|
||||
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
|
||||
warrior: Object.freeze({
|
||||
classId: "warrior",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("warrior-sword", "sword", "right", 1.04),
|
||||
item("warrior-shield", "shield", "left", 0.96),
|
||||
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
paladin: Object.freeze({
|
||||
classId: "paladin",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("paladin-mace", "mace", "right", 1.04),
|
||||
item("paladin-shield", "shield", "left", 0.98),
|
||||
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
hunter: Object.freeze({
|
||||
classId: "hunter",
|
||||
stance: "bow",
|
||||
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
|
||||
}),
|
||||
rogue: Object.freeze({
|
||||
classId: "rogue",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("rogue-main-dagger", "dagger", "right", 0.94),
|
||||
item("rogue-off-dagger", "dagger", "left", 0.94),
|
||||
@@ -70,14 +81,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
priest: Object.freeze({
|
||||
classId: "priest",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
|
||||
}),
|
||||
"death-knight": Object.freeze({
|
||||
classId: "death-knight",
|
||||
stance: "two-handed",
|
||||
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
|
||||
}),
|
||||
shaman: Object.freeze({
|
||||
classId: "shaman",
|
||||
stance: "one-handed",
|
||||
items: Object.freeze([
|
||||
item("shaman-mace", "mace", "right", 1),
|
||||
item("shaman-shield", "shield", "left", 0.9),
|
||||
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
|
||||
}),
|
||||
mage: Object.freeze({
|
||||
classId: "mage",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
|
||||
}),
|
||||
warlock: Object.freeze({
|
||||
classId: "warlock",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
|
||||
}),
|
||||
druid: Object.freeze({
|
||||
classId: "druid",
|
||||
stance: "two-handed-left",
|
||||
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]),
|
||||
}),
|
||||
});
|
||||
@@ -103,6 +120,7 @@ export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoad
|
||||
return [characterClass.id, Object.freeze({
|
||||
classId: characterClass.id,
|
||||
items: source.items,
|
||||
stance: source.stance,
|
||||
})];
|
||||
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
|
||||
);
|
||||
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
|
||||
return STARTER_WEAPON_LOADOUTS[classId];
|
||||
}
|
||||
|
||||
export function starterWeaponStance(classId: ClassId): StarterWeaponStance {
|
||||
return starterWeaponLoadout(classId).stance;
|
||||
}
|
||||
|
||||
type VisibleWeaponItem = Pick<InventoryItem, "inventorySlot" | "itemId" | "weaponType">;
|
||||
|
||||
/** Selects a ready pose from the weapon models that are actually being rendered. */
|
||||
export function characterWeaponStance(
|
||||
classId: ClassId,
|
||||
equipment: readonly VisibleWeaponItem[] = [],
|
||||
visibleWeaponItemIds: readonly number[] = [],
|
||||
): StarterWeaponStance {
|
||||
const visibleIds = new Set(visibleWeaponItemIds);
|
||||
const visibleWeapons = equipment.filter((equipped) => (
|
||||
equipped.weaponType && visibleIds.has(equipped.itemId)
|
||||
));
|
||||
if (visibleWeapons.some((equipped) => equipped.weaponType === "bow")) return "bow";
|
||||
const weapon = visibleWeapons.find((equipped) => equipped.inventorySlot !== "ranged")
|
||||
?? visibleWeapons[0];
|
||||
if (!weapon) return starterWeaponStance(classId);
|
||||
if (weapon.weaponType === "staff") return "two-handed-left";
|
||||
return weapon.inventorySlot === "two-hand" ? "two-handed" : "one-handed";
|
||||
}
|
||||
|
||||
export function starterWeaponItemsForAvailableHands(
|
||||
classId: ClassId,
|
||||
occupiedHands: readonly AvatarHand[] = [],
|
||||
@@ -124,6 +166,13 @@ const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freez
|
||||
right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_HAND_PIVOT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
|
||||
left: /^p_left_hand$/i,
|
||||
right: /^p_right_hand$/i,
|
||||
});
|
||||
|
||||
const EXPLICIT_SHIELD_PIVOT_PATTERN = /^p_hand_shield$/i;
|
||||
|
||||
const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
|
||||
left: Object.freeze([
|
||||
/(?:^|[_ .-])L Hand$/i,
|
||||
@@ -142,11 +191,19 @@ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Obje
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds a playable-character hand without relying on exporter-specific numeric
|
||||
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender
|
||||
* export, and their shared parent is the animated hand bone.
|
||||
* Finds a playable-character grip without relying on exporter-specific numeric
|
||||
* bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
|
||||
* named finger pivots otherwise identify their shared animated hand bone.
|
||||
*/
|
||||
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_HAND_PIVOT_PATTERN[hand].test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
if (explicitPivot) return explicitPivot;
|
||||
|
||||
const votes = new Map<Object3D, number>();
|
||||
root.traverse((node) => {
|
||||
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return;
|
||||
@@ -171,6 +228,16 @@ export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D |
|
||||
return namedFallback;
|
||||
}
|
||||
|
||||
export function findAvatarShieldBone(root: Object3D): Object3D | null {
|
||||
let explicitPivot: Object3D | null = null;
|
||||
root.traverse((node) => {
|
||||
if (!explicitPivot && EXPLICIT_SHIELD_PIVOT_PATTERN.test(node.name)) {
|
||||
explicitPivot = node;
|
||||
}
|
||||
});
|
||||
return explicitPivot ?? findAvatarHandBone(root, "left");
|
||||
}
|
||||
|
||||
export interface AvatarHandBones {
|
||||
readonly left: Object3D | null;
|
||||
readonly right: Object3D | null;
|
||||
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
|
||||
export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
|
||||
|
||||
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
|
||||
const STARTER_WEAPON_MODEL_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([Math.PI / 2, 0, -Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const APPROXIMATE_WEAPON_GRIP_ROTATIONS: Readonly<
|
||||
Record<StarterWeaponKind, StarterWeaponRotation>
|
||||
> = Object.freeze({
|
||||
bow: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
dagger: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
mace: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
shield: ZERO_ROTATION,
|
||||
staff: Object.freeze([0, 0, Math.PI / 2] as const),
|
||||
sword: Object.freeze([0, 0, -Math.PI / 2] as const),
|
||||
});
|
||||
|
||||
const RUNEWAKER_ONE_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
-Math.PI / 2,
|
||||
0,
|
||||
]);
|
||||
const RUNEWAKER_TWO_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
|
||||
0,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
]);
|
||||
|
||||
/** Normalizes procedural geometry to the same local axes as native item GLBs. */
|
||||
export function starterWeaponModelRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return STARTER_WEAPON_MODEL_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Synthetic hand pose used only while the animated character rig is absent. */
|
||||
export function approximateStarterWeaponGripRotation(
|
||||
kind: StarterWeaponKind,
|
||||
): StarterWeaponRotation {
|
||||
return APPROXIMATE_WEAPON_GRIP_ROTATIONS[kind];
|
||||
}
|
||||
|
||||
/** Adapts the canonical WoW +X item frame to a rig's authored prop socket. */
|
||||
export function avatarWeaponSocketRotation(
|
||||
entry: AvatarManifestEntry | undefined,
|
||||
kind: StarterWeaponKind,
|
||||
stance: StarterWeaponStance,
|
||||
): StarterWeaponRotation {
|
||||
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
|
||||
return ZERO_ROTATION;
|
||||
}
|
||||
return stance === "two-handed" || stance === "two-handed-left"
|
||||
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
|
||||
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
|
||||
}
|
||||
|
||||
const STRIKE_DURATION_SECONDS = 0.54;
|
||||
|
||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
@@ -374,16 +502,16 @@ function BowVisual() {
|
||||
|
||||
function ShieldVisual() {
|
||||
return (
|
||||
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}>
|
||||
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||
<group name="starter-shield-geometry" position={[0, 0, -0.08]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
|
||||
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
|
||||
</mesh>
|
||||
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<mesh position={[0, 0, -0.045]}>
|
||||
<torusGeometry args={[0.3, 0.035, 6, 16]} />
|
||||
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
|
||||
</mesh>
|
||||
@@ -392,12 +520,22 @@ function ShieldVisual() {
|
||||
}
|
||||
|
||||
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
|
||||
if (kind === "bow") return <BowVisual />;
|
||||
if (kind === "dagger") return <DaggerVisual />;
|
||||
if (kind === "mace") return <MaceVisual />;
|
||||
if (kind === "shield") return <ShieldVisual />;
|
||||
if (kind === "staff") return <StaffVisual />;
|
||||
return <SwordVisual />;
|
||||
const visual = kind === "bow"
|
||||
? <BowVisual />
|
||||
: kind === "dagger"
|
||||
? <DaggerVisual />
|
||||
: kind === "mace"
|
||||
? <MaceVisual />
|
||||
: kind === "shield"
|
||||
? <ShieldVisual />
|
||||
: kind === "staff"
|
||||
? <StaffVisual />
|
||||
: <SwordVisual />;
|
||||
return (
|
||||
<group rotation={starterWeaponModelRotation(kind)}>
|
||||
{visual}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StarterWeaponVisualProps {
|
||||
@@ -449,19 +587,37 @@ export function StarterWeaponVisual({
|
||||
interface AttachedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly attachmentScale: Vector3;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly position: Vector3;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
readonly target: Object3D;
|
||||
}
|
||||
|
||||
interface ResolvedStarterWeaponProps {
|
||||
readonly attackPulse: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly fallbackTarget: Object3D | null;
|
||||
readonly item: StarterWeaponItem;
|
||||
readonly point?: AvatarAttachmentPoint;
|
||||
readonly root: Object3D;
|
||||
readonly scale: number;
|
||||
readonly socketRotation: StarterWeaponRotation;
|
||||
}
|
||||
|
||||
function AttachedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
attachmentScale,
|
||||
item: equipped,
|
||||
position,
|
||||
scale,
|
||||
socketRotation,
|
||||
target,
|
||||
}: AttachedStarterWeaponProps) {
|
||||
return createPortal(
|
||||
<group position={position} rotation={socketRotation} scale={attachmentScale}>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
@@ -469,15 +625,50 @@ function AttachedStarterWeapon({
|
||||
kind={equipped.kind}
|
||||
name={`starter-equipment-${equipped.id}`}
|
||||
scale={scale * equipped.scale}
|
||||
/>,
|
||||
/>
|
||||
</group>,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedStarterWeapon({
|
||||
attackPulse,
|
||||
attackPulseRef,
|
||||
fallbackTarget,
|
||||
item: equipped,
|
||||
point,
|
||||
root,
|
||||
scale,
|
||||
socketRotation,
|
||||
}: ResolvedStarterWeaponProps): ReactNode {
|
||||
const transform = useMemo(
|
||||
() => resolveAvatarAttachmentPoint(
|
||||
root,
|
||||
point,
|
||||
fallbackTarget,
|
||||
{ useTargetOrigin: true },
|
||||
),
|
||||
[fallbackTarget, point, root],
|
||||
);
|
||||
return transform ? (
|
||||
<AttachedStarterWeapon
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
attachmentScale={transform.scale}
|
||||
item={equipped}
|
||||
position={transform.position}
|
||||
scale={scale}
|
||||
socketRotation={socketRotation}
|
||||
target={transform.target}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export interface StarterWeaponAttachmentsProps {
|
||||
readonly attackPulse?: number;
|
||||
readonly attackPulseRef?: MutableRefObject<number>;
|
||||
readonly classId: ClassId;
|
||||
readonly entry?: AvatarManifestEntry;
|
||||
readonly occupiedHands?: readonly AvatarHand[];
|
||||
readonly onlyHands?: readonly AvatarHand[];
|
||||
readonly root: Object3D;
|
||||
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
|
||||
attackPulse = 0,
|
||||
attackPulseRef,
|
||||
classId,
|
||||
entry,
|
||||
occupiedHands = [],
|
||||
onlyHands,
|
||||
root,
|
||||
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
|
||||
[hands, root, scale],
|
||||
);
|
||||
const allowedHands = onlyHands ? new Set(onlyHands) : null;
|
||||
const stance = starterWeaponStance(classId);
|
||||
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
|
||||
.filter((item) => !allowedHands || allowedHands.has(item.hand));
|
||||
return items.map((equipped) => {
|
||||
const target = hands[equipped.hand];
|
||||
return target ? (
|
||||
<AttachedStarterWeapon
|
||||
const attachmentName = equipped.kind === "shield"
|
||||
? "shield"
|
||||
: equipped.hand === "left" ? "handLeft" : "handRight";
|
||||
const fallbackTarget = equipped.kind === "shield"
|
||||
? findAvatarShieldBone(root)
|
||||
: hands[equipped.hand];
|
||||
return (
|
||||
<ResolvedStarterWeapon
|
||||
key={equipped.id}
|
||||
attackPulse={attackPulse}
|
||||
attackPulseRef={attackPulseRef}
|
||||
fallbackTarget={fallbackTarget}
|
||||
item={equipped}
|
||||
point={entry?.attachmentPoints?.[attachmentName]}
|
||||
root={root}
|
||||
scale={equipmentScale}
|
||||
target={target}
|
||||
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
|
||||
<group
|
||||
key={equipped.id}
|
||||
position={handPositions[equipped.hand]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
|
||||
>
|
||||
<StarterWeaponVisual
|
||||
attackPulse={attackPulse}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
describe("combatSimulationTimerShouldRun", () => {
|
||||
it("runs during active local gameplay", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
paused: false,
|
||||
companionOpen: false,
|
||||
documentHidden: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ paused: true, companionOpen: false, documentHidden: false },
|
||||
{ paused: false, companionOpen: true, documentHidden: false },
|
||||
{ paused: false, companionOpen: false, documentHidden: true },
|
||||
])("stops inactive local gameplay for %o", (state) => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
...state,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves shared-online simulation in every local lifecycle state", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: true,
|
||||
paused: true,
|
||||
companionOpen: true,
|
||||
documentHidden: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
+80
-56
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
onlineSessionIsActive,
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -42,73 +44,55 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = `${character.level}:${character.experience}:${JSON.stringify(character.talentRanks)}`;
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = `${state.level}:${state.xp}:${JSON.stringify(state.talentRanks)}`;
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
const characterId = character.id;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
// serializing each collection, without doing five full-store callbacks
|
||||
// and JSON.stringify work for every 100 ms simulation notification.
|
||||
if (
|
||||
state.level !== previousState.level
|
||||
|| state.xp !== previousState.xp
|
||||
|| state.talentRanks !== previousState.talentRanks
|
||||
) {
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
useShellStore.getState().saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = JSON.stringify(useCombatStore.getState().actionBindings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = JSON.stringify(state.actionBindings);
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode === "manastorm") return;
|
||||
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
if (useGameStore.getState().gameMode !== "manastorm") {
|
||||
shell.saveActiveActionBindings(state.actionBindings);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastInventory = JSON.stringify(character.inventory);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextInventory = JSON.stringify(state.inventory);
|
||||
if (nextInventory === lastInventory) return;
|
||||
lastInventory = nextInventory;
|
||||
useShellStore.getState().saveActiveInventory(state.inventory);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastEquipment = JSON.stringify(character.equipment ?? {});
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextEquipment = JSON.stringify(state.equipment);
|
||||
if (nextEquipment === lastEquipment) return;
|
||||
lastEquipment = nextEquipment;
|
||||
useShellStore.getState().saveActiveEquipment(state.equipment);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastSettings = JSON.stringify(useCombatStore.getState().settings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextSettings = JSON.stringify(state.settings);
|
||||
if (nextSettings === lastSettings) return;
|
||||
lastSettings = nextSettings;
|
||||
useShellStore.getState().saveActiveSettings(state.settings);
|
||||
if (state.inventory !== previousState.inventory) {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
}
|
||||
if (state.settings !== previousState.settings) {
|
||||
shell.saveActiveSettings(state.settings);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
let timer: number | null = null;
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
@@ -138,8 +122,48 @@ export function CombatBridge() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
};
|
||||
|
||||
const reconcileTimer = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shouldRun = combatSimulationTimerShouldRun({
|
||||
sharedOnline: onlineSessionIsActive(),
|
||||
paused: game.paused,
|
||||
companionOpen: game.companionOpen,
|
||||
documentHidden: document.hidden,
|
||||
});
|
||||
if (shouldRun && timer === null) {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
// leak into the first simulation delta.
|
||||
previous = performance.now();
|
||||
timer = window.setInterval(advance, 100);
|
||||
} else if (!shouldRun && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
state.paused !== previousState.paused
|
||||
|| state.companionOpen !== previousState.companionOpen
|
||||
) {
|
||||
reconcileTimer();
|
||||
}
|
||||
});
|
||||
// Online runtime configuration is intentionally module-local; the session
|
||||
// store notification gives the bridge a reactive point at which to
|
||||
// reconcile while preserving continuous shared-online simulation.
|
||||
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
||||
document.addEventListener("visibilitychange", reconcileTimer);
|
||||
reconcileTimer();
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
unsubscribeGame();
|
||||
unsubscribeOnlineSession();
|
||||
document.removeEventListener("visibilitychange", reconcileTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("filters classes without changing their canonical definitions", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(60, ["mage", "rom-priest"]);
|
||||
|
||||
expect(builds.map((build) => `${build.classId}:${build.role}`)).toEqual([
|
||||
"mage:damage",
|
||||
"rom-priest:healer",
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BASE_CLASS_IDS,
|
||||
CLASSES,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
type CoaClassId,
|
||||
type RomClassId,
|
||||
} from "../../app/characterCatalog";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityAiHints,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
runtimeAllocateTalentPoint,
|
||||
runtimeNormalizeTalentRanks,
|
||||
type RuntimeTalentRanks,
|
||||
} from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadTalentUiCatalog,
|
||||
type TalentUiCatalog,
|
||||
type TalentUiNode,
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
> = Object.freeze({
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
warlock: { damage: "Affliction" },
|
||||
druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" },
|
||||
});
|
||||
|
||||
type AbilityAiCategory = AbilityAiHints["categories"][number];
|
||||
|
||||
/**
|
||||
* The large gaps keep the primary responsibility of a role ahead of merely
|
||||
* having more utility buttons. These are only selection weights; allocation
|
||||
* legality and point costs remain entirely owned by the runtime talent system.
|
||||
*/
|
||||
const COA_ROLE_CATEGORY_WEIGHTS: Readonly<
|
||||
Record<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
|
||||
> = Object.freeze({
|
||||
tank: Object.freeze({
|
||||
taunt: 1_000,
|
||||
mitigation: 500,
|
||||
control: 50,
|
||||
interrupt: 50,
|
||||
heal: 20,
|
||||
"emergency-heal": 20,
|
||||
dispel: 15,
|
||||
debuff: 10,
|
||||
execute: 10,
|
||||
damage: 10,
|
||||
}),
|
||||
healer: Object.freeze({
|
||||
"emergency-heal": 800,
|
||||
"area-heal": 700,
|
||||
heal: 500,
|
||||
mitigation: 100,
|
||||
dispel: 80,
|
||||
resurrection: 60,
|
||||
control: 20,
|
||||
interrupt: 15,
|
||||
debuff: 5,
|
||||
damage: 2,
|
||||
}),
|
||||
damage: Object.freeze({
|
||||
execute: 200,
|
||||
damage: 100,
|
||||
debuff: 40,
|
||||
interrupt: 30,
|
||||
control: 20,
|
||||
mitigation: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
interface CoaRoleTextPattern {
|
||||
readonly pattern: RegExp;
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
// CoA specialization trees contain many passive nodes with no executable
|
||||
// ability (and consequently no AbilityAiHints). Catalog text is a stable
|
||||
// secondary signal for those nodes; executable ability hints still contribute
|
||||
// directly whenever the runtime catalog provides them.
|
||||
const COA_ROLE_TEXT_PATTERNS: Readonly<Record<PartyRole, readonly CoaRoleTextPattern[]>> = Object.freeze({
|
||||
tank: Object.freeze([
|
||||
{ pattern: /\b(?:taunt|threat)\b/i, weight: 300 },
|
||||
{ pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 },
|
||||
{ pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 },
|
||||
{ pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 },
|
||||
{ pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 },
|
||||
{ pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 },
|
||||
{ pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 },
|
||||
{ pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 },
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
{ pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 },
|
||||
{ pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 },
|
||||
{ pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 },
|
||||
{ pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 },
|
||||
{ pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 },
|
||||
]),
|
||||
damage: Object.freeze([
|
||||
{ pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 },
|
||||
{ pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 },
|
||||
{ pattern: /\b(?:enemy|enemies)\b/i, weight: 5 },
|
||||
]),
|
||||
});
|
||||
|
||||
function isBaseClassId(classId: ClassId): classId is BaseClassId {
|
||||
return BASE_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isCoaClassId(classId: ClassId): classId is CoaClassId {
|
||||
return COA_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isRomClassId(classId: ClassId): classId is RomClassId {
|
||||
return ROM_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string {
|
||||
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, level),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
secondaryClassId: null,
|
||||
role,
|
||||
level,
|
||||
inventory: EMPTY_INVENTORY,
|
||||
equipment: EMPTY_EQUIPMENT,
|
||||
gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function wowBuild(
|
||||
classId: BaseClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
});
|
||||
}
|
||||
|
||||
function isClassTree(tree: TalentUiTree): boolean {
|
||||
return tree.name.trim().toLowerCase() === "class";
|
||||
}
|
||||
|
||||
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
||||
const mutable = new Map<string, AbilityDefinition[]>();
|
||||
for (const ability of abilitiesForClass(classId)) {
|
||||
if (ability.talentEntryId === undefined) continue;
|
||||
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
||||
const existing = mutable.get(nodeId);
|
||||
if (existing) existing.push(ability);
|
||||
else mutable.set(nodeId, [ability]);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const weights = COA_ROLE_CATEGORY_WEIGHTS[role];
|
||||
const categoryScore = hints.categories.reduce(
|
||||
(total, category) => total + (weights[category] ?? 0),
|
||||
0,
|
||||
);
|
||||
if (categoryScore <= 0) return 0;
|
||||
const priority = Number.isFinite(hints.priority)
|
||||
? Math.max(0, Math.min(100, hints.priority ?? 0))
|
||||
: 0;
|
||||
return categoryScore + priority;
|
||||
}
|
||||
|
||||
function coaNodeRoleScore(
|
||||
nodeId: string,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return (abilitiesByNode.get(nodeId) ?? []).reduce(
|
||||
(highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number {
|
||||
const searchable = `${node.name} ${node.description}`;
|
||||
return COA_ROLE_TEXT_PATTERNS[role].reduce(
|
||||
(total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeSelectionScore(
|
||||
node: TalentUiNode,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
|
||||
}
|
||||
|
||||
function coaTreeRoleScore(
|
||||
tree: TalentUiTree,
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return catalog.nodes.reduce((total, node) => (
|
||||
node.treeId === tree.id
|
||||
? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank
|
||||
: total
|
||||
), 0);
|
||||
}
|
||||
|
||||
function preferredCoaTree(
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): TalentUiTree {
|
||||
const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree));
|
||||
const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees;
|
||||
const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index]));
|
||||
const selected = [...candidates].sort((left, right) => (
|
||||
coaTreeRoleScore(right, catalog, role, abilitiesByNode)
|
||||
- coaTreeRoleScore(left, catalog, role, abilitiesByNode)
|
||||
|| (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0)
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function orderedCoaAllocationCandidates(
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): readonly TalentUiNode[] {
|
||||
const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id));
|
||||
const treePriority = (node: TalentUiNode) => (
|
||||
node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2
|
||||
);
|
||||
return catalog.nodes
|
||||
.filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId))
|
||||
.sort((left, right) => (
|
||||
treePriority(left) - treePriority(right)
|
||||
|| coaNodeSelectionScore(right, role, abilitiesByNode)
|
||||
- coaNodeSelectionScore(left, role, abilitiesByNode)
|
||||
|| left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
function allocateCoaTalentRanks(
|
||||
classId: CoaClassId,
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): RuntimeTalentRanks {
|
||||
const candidates = orderedCoaAllocationCandidates(
|
||||
catalog,
|
||||
preferredTree,
|
||||
role,
|
||||
abilitiesByNode,
|
||||
);
|
||||
const successfulRequests: Record<string, number> = {};
|
||||
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
|
||||
|
||||
// A successful request is never repeated, even if normalization later omits
|
||||
// a free node. This keeps zero-cost prerequisite layouts finite while failed
|
||||
// gated nodes remain eligible after lower rows have been filled.
|
||||
while (true) {
|
||||
let allocated = false;
|
||||
for (const node of candidates) {
|
||||
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
|
||||
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
|
||||
if (!result.ok) continue;
|
||||
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
|
||||
ranks = result.ranks;
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (!allocated) break;
|
||||
}
|
||||
|
||||
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
|
||||
}
|
||||
|
||||
function coaBuild(
|
||||
classId: CoaClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog,
|
||||
): BenchmarkBuildDefinition {
|
||||
const abilitiesByNode = coaNodeAbilities(classId);
|
||||
const orientation = preferredCoaTree(catalog, role, abilitiesByNode);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: orientation.name,
|
||||
talentRanks: allocateCoaTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
orientation,
|
||||
role,
|
||||
level,
|
||||
abilitiesByNode,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function romBuild(
|
||||
classId: RomClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
const coaCatalogs = new Map<CoaClassId, TalentUiCatalog>(await Promise.all(
|
||||
selectedCoaClassIds.map(async (classId) => [
|
||||
classId,
|
||||
await loadTalentUiCatalog(classId),
|
||||
] as const),
|
||||
));
|
||||
|
||||
const builds: BenchmarkBuildDefinition[] = [];
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
builds.push(coaBuild(classId, role, safeLevel, catalog));
|
||||
} else if (isRomClassId(classId)) {
|
||||
builds.push(romBuild(classId, role, safeLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import type { CastFailureReason, CombatState } from "../combatStore";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { getMobPosition } from "../mobRuntimeRegistry";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v1";
|
||||
|
||||
export interface BenchmarkPolicyDecision {
|
||||
readonly made: boolean;
|
||||
readonly cast: boolean;
|
||||
readonly resourceBlocked: boolean;
|
||||
readonly invalidFailures: number;
|
||||
}
|
||||
|
||||
interface FriendlyTarget {
|
||||
readonly id: string;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
readonly missingPercent: number;
|
||||
}
|
||||
|
||||
interface ScoredAbility {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly score: number;
|
||||
readonly friendlyTarget: FriendlyTarget | null;
|
||||
}
|
||||
|
||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||
"already-casting",
|
||||
"global-cooldown",
|
||||
"cooldown",
|
||||
"not-enough-resource",
|
||||
"not-enough-runes",
|
||||
"condition-not-met",
|
||||
"player-controlled",
|
||||
"school-locked",
|
||||
]);
|
||||
|
||||
function healthTarget(id: string, health: number, maxHealth: number): FriendlyTarget {
|
||||
return {
|
||||
id,
|
||||
health,
|
||||
maxHealth,
|
||||
missingPercent: maxHealth > 0 ? Math.max(0, 1 - health / maxHealth) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyTargets(combat: CombatState, party: PartyState): readonly FriendlyTarget[] {
|
||||
return [
|
||||
healthTarget(PLAYER_AGGRO_ID, combat.health, combat.maxHealth),
|
||||
...party.members.map((member) => healthTarget(member.id, member.health, member.maxHealth)),
|
||||
]
|
||||
.filter((target) => target.health > 0)
|
||||
.sort((left, right) => right.missingPercent - left.missingPercent || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
|
||||
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
|
||||
const learnedRank = ability.talentEntryId === undefined
|
||||
? undefined
|
||||
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
||||
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return ranked;
|
||||
}
|
||||
|
||||
function isAreaAbility(ability: AbilityDefinition): boolean {
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
return targeting.shape !== "unit"
|
||||
|| (targeting.maximumTargets ?? 1) > 1
|
||||
|| (targeting.radius ?? ability.radius ?? 0) > 0;
|
||||
}
|
||||
|
||||
function throughputWeight(ability: AbilityDefinition): number {
|
||||
let weight = 0;
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
weight += Math.max(0, effect.basePoints ?? 0) * 0.01;
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
weight += Math.max(0, effect.baseCoefficient + effect.perComboCoefficient * 3) * 10;
|
||||
} else if (effect.kind === "execute") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
}
|
||||
}
|
||||
return Math.min(40, weight);
|
||||
}
|
||||
|
||||
function scoreAbility(
|
||||
ability: AbilityDefinition,
|
||||
role: PartyRole,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
combat: CombatState,
|
||||
targets: readonly FriendlyTarget[],
|
||||
): ScoredAbility | null {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const categories = new Set(hints.categories);
|
||||
const injured = targets.filter((target) => target.missingPercent > 0.005);
|
||||
const lowest = targets[0] ?? null;
|
||||
const area = isAreaAbility(ability);
|
||||
let score = hints.priority ?? 0;
|
||||
let friendlyTarget: FriendlyTarget | null = null;
|
||||
|
||||
if (role === "healer") {
|
||||
const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
|
||||
const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield");
|
||||
if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null;
|
||||
friendlyTarget = ability.target === "self"
|
||||
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
|
||||
: lowest;
|
||||
score += canHeal ? 100 : 70;
|
||||
score += friendlyTarget.missingPercent * 180;
|
||||
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
|
||||
if (categories.has("area-heal") || area) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
|
||||
} else {
|
||||
const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff");
|
||||
if (role === "damage" && !hostileDamage) return null;
|
||||
if (role === "tank" && !hostileDamage && !categories.has("mitigation") && !categories.has("taunt")) return null;
|
||||
if (hostileDamage) score += 100 + throughputWeight(ability);
|
||||
if (categories.has("execute")) {
|
||||
const target = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
|
||||
const executeActive = Boolean(target && target.health / Math.max(1, target.maxHealth) <= 0.2);
|
||||
score += executeActive ? 120 : -180;
|
||||
}
|
||||
if (scenario.hostileTargets > 1) score += area ? 60 + scenario.hostileTargets * 4 : -15;
|
||||
else if (area) score -= 15;
|
||||
if (ability.effects.some((effect) => effect.kind === "dot")
|
||||
&& combat.effects.some((effect) => effect.sourceAbilityId === ability.id && effect.targetId === combat.selectedTargetId)) {
|
||||
score -= 250;
|
||||
}
|
||||
if (role === "tank") {
|
||||
const playerHealth = combat.health / Math.max(1, combat.maxHealth);
|
||||
if (categories.has("mitigation")) score += playerHealth < 0.8 ? 150 : 20;
|
||||
if (categories.has("taunt")) {
|
||||
const looseMobs = Object.values(combat.mobs).filter((mob) => !mob.dead && mob.targetActorId !== PLAYER_AGGRO_ID).length;
|
||||
score += looseMobs > 0 ? 220 : -80;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ability, score, friendlyTarget };
|
||||
}
|
||||
|
||||
/** A deterministic, shared baseline policy. It is intentionally not an optimizer. */
|
||||
export class BenchmarkController {
|
||||
#nextDecisionAt = 0;
|
||||
|
||||
act(now: number, role: PartyRole, scenario: BenchmarkScenarioDefinition, moving: boolean): BenchmarkPolicyDecision {
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
if (
|
||||
now < this.#nextDecisionAt
|
||||
|| combat.health <= 0
|
||||
|| combat.activeCast
|
||||
|| combat.globalCooldownEndsAt > now
|
||||
|| combat.controlledUntil > now
|
||||
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
||||
|
||||
const targets = friendlyTargets(combat, party);
|
||||
const candidates = combat.abilities
|
||||
.map((ability) => learnedAbility(ability, combat))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
|
||||
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
||||
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||
|
||||
let resourceBlocked = false;
|
||||
let invalidFailures = 0;
|
||||
for (const candidate of candidates) {
|
||||
const { ability, friendlyTarget } = candidate;
|
||||
let targetActorId: string | null | undefined;
|
||||
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||
if (friendlyTarget?.id === PLAYER_AGGRO_ID) {
|
||||
party.selectSelf();
|
||||
targetActorId = null;
|
||||
} else if (friendlyTarget) {
|
||||
party.selectMember(friendlyTarget.id);
|
||||
targetActorId = friendlyTarget.id;
|
||||
}
|
||||
} else if (ability.target === "hostile" || ability.target === "any") {
|
||||
party.clearSelection();
|
||||
useCombatStore.getState().selectFriendlyActor(null);
|
||||
targetActorId = combat.selectedTargetId;
|
||||
}
|
||||
const hostilePosition = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const result = useCombatStore.getState().castAbility(ability.id, {
|
||||
now,
|
||||
origin: combat.playerPosition,
|
||||
targetActorId,
|
||||
...(targeting.shape === "ground"
|
||||
? { groundPosition: ability.target === "hostile" || ability.target === "any"
|
||||
? hostilePosition ?? combat.playerPosition
|
||||
: combat.playerPosition }
|
||||
: {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
this.#nextDecisionAt = now + 50;
|
||||
return { made: true, cast: true, resourceBlocked: false, invalidFailures };
|
||||
}
|
||||
if (result.reason === "not-enough-resource" || result.reason === "not-enough-runes") resourceBlocked = true;
|
||||
if (result.reason && !TRANSIENT_FAILURES.has(result.reason)) invalidFailures += 1;
|
||||
}
|
||||
|
||||
this.#nextDecisionAt = now + 1_500;
|
||||
return { made: true, cast: false, resourceBlocked, invalidFailures };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { resourceProfileForClass, type ResourceType } from "../abilityCatalog";
|
||||
import type { CombatState } from "../combatStore";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "../combatEvents";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import type {
|
||||
BenchmarkAbilityMetrics,
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkResourceMetrics,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
castsStarted: number;
|
||||
castsCompleted: number;
|
||||
interrupts: number;
|
||||
rawDamage: number;
|
||||
effectiveDamage: number;
|
||||
effectiveHealing: number;
|
||||
overheal: number;
|
||||
absorbsApplied: number;
|
||||
}
|
||||
|
||||
interface MutableResourceMetrics {
|
||||
starting: number;
|
||||
ending: number;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRecorderSample {
|
||||
readonly now: number;
|
||||
readonly deltaMs: number;
|
||||
readonly combat: CombatState;
|
||||
readonly party: PartyState;
|
||||
readonly movedDistance: number;
|
||||
readonly moving: boolean;
|
||||
readonly stackCompliant: boolean | null;
|
||||
readonly heldMobs: number;
|
||||
readonly totalThreatMobs: number;
|
||||
}
|
||||
|
||||
const emptyAbilityMetrics = (): MutableAbilityMetrics => ({
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
interrupts: 0,
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
});
|
||||
|
||||
function percent(numerator: number, denominator: number): number {
|
||||
return denominator > 0 ? numerator / denominator * 100 : 0;
|
||||
}
|
||||
|
||||
/** Collects the uncapped event stream and fixed-step state samples for one run. */
|
||||
export class BenchmarkRecorder {
|
||||
readonly #build: BenchmarkBuildDefinition;
|
||||
readonly #scenario: BenchmarkScenarioDefinition;
|
||||
readonly #seed: number;
|
||||
readonly #startedAt: number;
|
||||
readonly #priorityTargetId: string;
|
||||
readonly #playerSources = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #friendlyTargets = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #eventCounts = new Map<string, number>();
|
||||
readonly #abilities = new Map<string, MutableAbilityMetrics>();
|
||||
readonly #resources = new Map<ResourceType, MutableResourceMetrics>();
|
||||
readonly #alive = new Map<string, boolean>();
|
||||
readonly #unsubscribe: () => void;
|
||||
|
||||
#rawDamage = 0;
|
||||
#effectiveDamage = 0;
|
||||
#priorityTargetDamage = 0;
|
||||
#effectiveHealing = 0;
|
||||
#rawHealing = 0;
|
||||
#overheal = 0;
|
||||
#absorbsApplied = 0;
|
||||
#absorbsConsumed = 0;
|
||||
#rawDamageTaken = 0;
|
||||
#effectiveDamageTaken = 0;
|
||||
#incomingFriendlyDamageEvents = 0;
|
||||
#incomingAttacks = 0;
|
||||
#avoidedAttacks = 0;
|
||||
#deaths = 0;
|
||||
#minimumPlayerHealthPercent = 100;
|
||||
#minimumPartyHealthPercent = 100;
|
||||
#castsStarted = 0;
|
||||
#castsCompleted = 0;
|
||||
#movementInterrupts = 0;
|
||||
#invalidCasts = 0;
|
||||
#distanceMoved = 0;
|
||||
#movementTimeMs = 0;
|
||||
#stackCompliantMs = 0;
|
||||
#stackObservedMs = 0;
|
||||
#threatHeldMobMs = 0;
|
||||
#threatObservedMobMs = 0;
|
||||
#timeToResourceFailureMs: number | null = null;
|
||||
#timeToFirstDeathMs: number | null = null;
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
seed: number,
|
||||
startedAt: number,
|
||||
priorityTargetId: string,
|
||||
) {
|
||||
this.#build = build;
|
||||
this.#scenario = scenario;
|
||||
this.#seed = seed;
|
||||
this.#startedAt = startedAt;
|
||||
this.#priorityTargetId = priorityTargetId;
|
||||
this.#unsubscribe = subscribeCombatEvents((event) => this.#recordEvent(event));
|
||||
}
|
||||
|
||||
trackPlayerSummons(combat: CombatState): void {
|
||||
for (const summon of combat.summons) {
|
||||
if (summon.ownerActorId === PLAYER_AGGRO_ID) this.#playerSources.add(summon.id);
|
||||
}
|
||||
}
|
||||
|
||||
recordInvalidCast(): void {
|
||||
this.#invalidCasts += 1;
|
||||
}
|
||||
|
||||
markResourceFailure(now: number): void {
|
||||
if (this.#timeToResourceFailureMs === null) {
|
||||
this.#timeToResourceFailureMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#unsubscribe();
|
||||
}
|
||||
|
||||
sample(sample: BenchmarkRecorderSample): void {
|
||||
const { combat, party, deltaMs } = sample;
|
||||
this.captureGearStats(combat);
|
||||
this.trackPlayerSummons(combat);
|
||||
this.#distanceMoved += Math.max(0, sample.movedDistance);
|
||||
if (sample.moving) this.#movementTimeMs += deltaMs;
|
||||
if (sample.stackCompliant !== null) {
|
||||
this.#stackObservedMs += deltaMs;
|
||||
if (sample.stackCompliant) this.#stackCompliantMs += deltaMs;
|
||||
}
|
||||
if (sample.totalThreatMobs > 0) {
|
||||
this.#threatObservedMobMs += sample.totalThreatMobs * deltaMs;
|
||||
this.#threatHeldMobMs += sample.heldMobs * deltaMs;
|
||||
}
|
||||
|
||||
const playerHealthPercent = percent(combat.health, combat.maxHealth);
|
||||
this.#minimumPlayerHealthPercent = Math.min(this.#minimumPlayerHealthPercent, playerHealthPercent);
|
||||
const livingPartyHealth = party.members
|
||||
.filter((member) => member.health > 0)
|
||||
.map((member) => percent(member.health, member.maxHealth));
|
||||
if (livingPartyHealth.length) {
|
||||
this.#minimumPartyHealthPercent = Math.min(this.#minimumPartyHealthPercent, ...livingPartyHealth);
|
||||
} else if (party.members.length) {
|
||||
this.#minimumPartyHealthPercent = 0;
|
||||
}
|
||||
|
||||
this.#observeAlive(PLAYER_AGGRO_ID, combat.health > 0, sample.now);
|
||||
for (const member of party.members) {
|
||||
this.#friendlyTargets.add(member.id);
|
||||
this.#observeAlive(member.id, member.health > 0, sample.now);
|
||||
}
|
||||
|
||||
const activeResource = resourceProfileForClass(combat.classId).type;
|
||||
const resourceIds = new Set<ResourceType>([
|
||||
activeResource,
|
||||
...Object.keys(combat.maxResourcePools) as ResourceType[],
|
||||
]);
|
||||
for (const id of resourceIds) {
|
||||
const maximum = id === activeResource
|
||||
? combat.maxResource
|
||||
: combat.maxResourcePools[id] ?? 0;
|
||||
const current = id === activeResource
|
||||
? combat.resource
|
||||
: combat.resourcePools[id] ?? 0;
|
||||
const metrics = this.#resources.get(id) ?? {
|
||||
starting: current,
|
||||
ending: current,
|
||||
minimum: current,
|
||||
maximum,
|
||||
timeBelowTenPercentMs: 0,
|
||||
};
|
||||
metrics.ending = current;
|
||||
metrics.minimum = Math.min(metrics.minimum, current);
|
||||
metrics.maximum = Math.max(metrics.maximum, maximum);
|
||||
if (maximum > 0 && current / maximum < 0.1) metrics.timeBelowTenPercentMs += deltaMs;
|
||||
this.#resources.set(id, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
finish(simulatedDurationMs: number, completed: boolean): BenchmarkRunReport {
|
||||
this.dispose();
|
||||
const seconds = Math.max(0.001, simulatedDurationMs / 1_000);
|
||||
const reasons: string[] = [];
|
||||
if (simulatedDurationMs <= 0) reasons.push("No combat time was simulated.");
|
||||
if (this.#castsCompleted <= 0 && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The policy completed no abilities and dealt no damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("damage") && this.#build.role === "damage" && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The damage build produced no effective damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("healer") && this.#build.role === "healer" && this.#incomingFriendlyDamageEvents <= 0 && this.#rawHealing <= 0) {
|
||||
reasons.push("The healing scenario produced neither incoming pressure nor healing.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("tank") && this.#build.role === "tank" && this.#incomingAttacks <= 0) {
|
||||
reasons.push("The tanking scenario produced no incoming player attacks.");
|
||||
}
|
||||
|
||||
const metrics: BenchmarkRunMetrics = Object.freeze({
|
||||
rawDamage: this.#rawDamage,
|
||||
effectiveDamage: this.#effectiveDamage,
|
||||
priorityTargetDamage: this.#priorityTargetDamage,
|
||||
effectiveHealing: this.#effectiveHealing,
|
||||
rawHealing: this.#rawHealing,
|
||||
overheal: this.#overheal,
|
||||
absorbsApplied: this.#absorbsApplied,
|
||||
absorbsConsumed: this.#absorbsConsumed,
|
||||
rawDamageTaken: this.#rawDamageTaken,
|
||||
effectiveDamageTaken: this.#effectiveDamageTaken,
|
||||
incomingAttacks: this.#incomingAttacks,
|
||||
avoidedAttacks: this.#avoidedAttacks,
|
||||
deaths: this.#deaths,
|
||||
minimumPlayerHealthPercent: this.#minimumPlayerHealthPercent,
|
||||
minimumPartyHealthPercent: this.#minimumPartyHealthPercent,
|
||||
castsStarted: this.#castsStarted,
|
||||
castsCompleted: this.#castsCompleted,
|
||||
movementInterrupts: this.#movementInterrupts,
|
||||
invalidCasts: this.#invalidCasts,
|
||||
distanceMoved: this.#distanceMoved,
|
||||
movementTimeMs: this.#movementTimeMs,
|
||||
stackCompliancePercent: this.#stackObservedMs > 0
|
||||
? percent(this.#stackCompliantMs, this.#stackObservedMs)
|
||||
: null,
|
||||
threatControlPercent: this.#threatObservedMobMs > 0
|
||||
? percent(this.#threatHeldMobMs, this.#threatObservedMobMs)
|
||||
: null,
|
||||
timeToResourceFailureMs: this.#timeToResourceFailureMs,
|
||||
timeToFirstDeathMs: this.#timeToFirstDeathMs,
|
||||
dps: this.#effectiveDamage / seconds,
|
||||
hps: this.#effectiveHealing / seconds,
|
||||
dtps: this.#effectiveDamageTaken / seconds,
|
||||
overhealPercent: percent(this.#overheal, this.#rawHealing),
|
||||
mitigationPercent: percent(this.#rawDamageTaken - this.#effectiveDamageTaken, this.#rawDamageTaken),
|
||||
avoidancePercent: percent(this.#avoidedAttacks, this.#incomingAttacks),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
seed: this.#seed,
|
||||
build: this.#build,
|
||||
scenario: this.#scenario,
|
||||
simulatedDurationMs,
|
||||
completed,
|
||||
metrics,
|
||||
resources: this.#resourceReport(),
|
||||
abilities: this.#abilityReport(),
|
||||
eventCounts: Object.freeze(Object.fromEntries([...this.#eventCounts].sort(([left], [right]) => left.localeCompare(right)))),
|
||||
validation: Object.freeze({ valid: reasons.length === 0, reasons: Object.freeze(reasons) }),
|
||||
gearStats: this.#lastGearStats,
|
||||
});
|
||||
}
|
||||
|
||||
#lastGearStats: BenchmarkRunReport["gearStats"] = {} as BenchmarkRunReport["gearStats"];
|
||||
|
||||
captureGearStats(combat: CombatState): void {
|
||||
this.#lastGearStats = combat.gearStats;
|
||||
}
|
||||
|
||||
#observeAlive(id: string, alive: boolean, now: number): void {
|
||||
const previous = this.#alive.get(id);
|
||||
this.#alive.set(id, alive);
|
||||
if (previous === undefined || !previous || alive) return;
|
||||
this.#deaths += 1;
|
||||
if (this.#timeToFirstDeathMs === null) this.#timeToFirstDeathMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
|
||||
#ability(id: string): MutableAbilityMetrics {
|
||||
const metrics = this.#abilities.get(id) ?? emptyAbilityMetrics();
|
||||
this.#abilities.set(id, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
#recordEvent(event: StructuredCombatEvent): void {
|
||||
this.#eventCounts.set(event.kind, (this.#eventCounts.get(event.kind) ?? 0) + 1);
|
||||
const playerSource = this.#playerSources.has(event.sourceActorId)
|
||||
|| event.sourceActorId.startsWith("player-summon-")
|
||||
|| event.sourceActorId.startsWith("player-triggered-summon-")
|
||||
|| event.sourceActorId.startsWith("player-pet-")
|
||||
|| event.sourceActorId.startsWith("player-utility-")
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:summon-`)
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:totem-`);
|
||||
const ability = playerSource && event.abilityId ? this.#ability(event.abilityId) : null;
|
||||
if (playerSource && event.kind === "damage" && !this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#rawDamage += event.rawAmount;
|
||||
this.#effectiveDamage += event.effectiveAmount;
|
||||
if (event.targetActorId === this.#priorityTargetId) this.#priorityTargetDamage += event.effectiveAmount;
|
||||
if (ability) {
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "healing") {
|
||||
this.#rawHealing += event.rawAmount;
|
||||
this.#effectiveHealing += event.effectiveAmount;
|
||||
this.#overheal += event.overheal;
|
||||
if (ability) {
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "absorb") {
|
||||
const amount = Math.max(event.effectiveAmount, event.absorbed);
|
||||
this.#absorbsApplied += amount;
|
||||
if (ability) ability.absorbsApplied += amount;
|
||||
}
|
||||
if (event.kind === "damage" && !playerSource && this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#incomingFriendlyDamageEvents += 1;
|
||||
this.#absorbsConsumed += event.absorbed;
|
||||
if (event.targetActorId === PLAYER_AGGRO_ID) {
|
||||
this.#incomingAttacks += 1;
|
||||
if (["miss", "dodge", "parry", "resist"].includes(event.outcome ?? "")) this.#avoidedAttacks += 1;
|
||||
this.#rawDamageTaken += event.rawAmount;
|
||||
this.#effectiveDamageTaken += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "cast" && ability) {
|
||||
if (event.outcome === "started") {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
} else if (event.outcome === "completed") {
|
||||
if (ability.castsStarted <= ability.castsCompleted) {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
}
|
||||
ability.castsCompleted += 1;
|
||||
this.#castsCompleted += 1;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "interrupt") {
|
||||
if (ability) ability.interrupts += 1;
|
||||
if (event.outcome === "movement") this.#movementInterrupts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#resourceReport(): readonly BenchmarkResourceMetrics[] {
|
||||
return Object.freeze([...this.#resources]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([id, metrics]) => Object.freeze({ id, ...metrics })));
|
||||
}
|
||||
|
||||
#abilityReport(): readonly BenchmarkAbilityMetrics[] {
|
||||
return Object.freeze([...this.#abilities]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([abilityId, metrics]) => Object.freeze({ abilityId, ...metrics })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "../itemStats";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
import {
|
||||
aggregateBenchmarkMatrix,
|
||||
benchmarkQuantile,
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
} from "./benchmarkReport";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
|
||||
const build: BenchmarkBuildDefinition = {
|
||||
id: "mage-burst",
|
||||
source: "wow",
|
||||
classId: "mage",
|
||||
secondaryClassId: null,
|
||||
role: "damage",
|
||||
level: 60,
|
||||
orientation: 'spell, "burst"',
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
gearPresetId: "level-60-baseline",
|
||||
};
|
||||
|
||||
const defaultMetrics: BenchmarkRunMetrics = {
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
priorityTargetDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
rawHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
absorbsConsumed: 0,
|
||||
rawDamageTaken: 0,
|
||||
effectiveDamageTaken: 0,
|
||||
incomingAttacks: 0,
|
||||
avoidedAttacks: 0,
|
||||
deaths: 0,
|
||||
minimumPlayerHealthPercent: 100,
|
||||
minimumPartyHealthPercent: 100,
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
movementInterrupts: 0,
|
||||
invalidCasts: 0,
|
||||
distanceMoved: 0,
|
||||
movementTimeMs: 0,
|
||||
stackCompliancePercent: null,
|
||||
threatControlPercent: null,
|
||||
timeToResourceFailureMs: null,
|
||||
timeToFirstDeathMs: null,
|
||||
dps: 0,
|
||||
hps: 0,
|
||||
dtps: 0,
|
||||
overhealPercent: 0,
|
||||
mitigationPercent: 0,
|
||||
avoidancePercent: 0,
|
||||
};
|
||||
|
||||
function run(
|
||||
seed: number,
|
||||
metrics: Partial<BenchmarkRunMetrics>,
|
||||
valid = true,
|
||||
): BenchmarkRunReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: `mage-burst:${seed}`,
|
||||
policyId: "baseline-v1",
|
||||
seed,
|
||||
build,
|
||||
scenario,
|
||||
simulatedDurationMs: scenario.durationMs,
|
||||
completed: seed !== 2,
|
||||
metrics: { ...defaultMetrics, ...metrics },
|
||||
resources: [],
|
||||
abilities: [],
|
||||
eventCounts: {},
|
||||
validation: { valid, reasons: valid ? [] : ["fixture invalid"] },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
};
|
||||
}
|
||||
|
||||
function matrix(): BenchmarkMatrixReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
appVersion: "test",
|
||||
cohort: {
|
||||
level: 60,
|
||||
gearPresetId: "level-60-baseline",
|
||||
seeds: [1, 2, 3, 4],
|
||||
durationScale: 1,
|
||||
policyId: "baseline-v1",
|
||||
},
|
||||
scenarios: [scenario],
|
||||
builds: [build],
|
||||
runs: [
|
||||
run(1, { dps: 10, priorityTargetDamage: 100 }),
|
||||
run(2, { dps: 20, priorityTargetDamage: 200 }),
|
||||
run(3, { dps: 30, priorityTargetDamage: 300 }),
|
||||
run(4, { dps: 999, priorityTargetDamage: 9_999 }, false),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("benchmark reporting", () => {
|
||||
it("uses deterministic interpolated quantiles", () => {
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.1)).toBe(12);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.5)).toBe(20);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.9)).toBe(28);
|
||||
expect(benchmarkQuantile([null], 0.5)).toBeNull();
|
||||
expect(() => benchmarkQuantile([1], 1.1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("aggregates validated runs and retains null-event counts", () => {
|
||||
const summary = aggregateBenchmarkMatrix(matrix());
|
||||
const [row] = summary.rows;
|
||||
|
||||
expect(summary).toMatchObject({ runCount: 4, validRunCount: 3, invalidRunCount: 1 });
|
||||
expect(row).toMatchObject({
|
||||
runCount: 4,
|
||||
validRunCount: 3,
|
||||
invalidRunCount: 1,
|
||||
completedRunCount: 3,
|
||||
});
|
||||
expect(row.metrics.dps).toEqual({
|
||||
count: 3,
|
||||
nullCount: 0,
|
||||
p10: 12,
|
||||
median: 20,
|
||||
p90: 28,
|
||||
});
|
||||
expect(row.metrics.timeToFirstDeathMs).toEqual({
|
||||
count: 0,
|
||||
nullCount: 3,
|
||||
p10: null,
|
||||
median: null,
|
||||
p90: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes complete metric rows as newline-clean CSV", () => {
|
||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||
|
||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
||||
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
||||
expect(csv.endsWith("\n")).toBe(true);
|
||||
expect(csv.endsWith("\n\n")).toBe(false);
|
||||
expect(csv).not.toContain("\r");
|
||||
});
|
||||
|
||||
it("renders separate role metrics instead of a composite ranking", () => {
|
||||
const markdown = renderBenchmarkMatrixMarkdown(matrix());
|
||||
|
||||
expect(markdown).toContain("## Damage");
|
||||
expect(markdown).toContain("DPS: 20 [12–28]");
|
||||
expect(markdown).toContain("1 invalid run(s) are excluded");
|
||||
expect(markdown).toContain("does not calculate a composite score");
|
||||
expect(markdown.endsWith("\n")).toBe(true);
|
||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type {
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioFamily,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_METRIC_KEYS = Object.freeze([
|
||||
"rawDamage",
|
||||
"effectiveDamage",
|
||||
"priorityTargetDamage",
|
||||
"effectiveHealing",
|
||||
"rawHealing",
|
||||
"overheal",
|
||||
"absorbsApplied",
|
||||
"absorbsConsumed",
|
||||
"rawDamageTaken",
|
||||
"effectiveDamageTaken",
|
||||
"incomingAttacks",
|
||||
"avoidedAttacks",
|
||||
"deaths",
|
||||
"minimumPlayerHealthPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"castsStarted",
|
||||
"castsCompleted",
|
||||
"movementInterrupts",
|
||||
"invalidCasts",
|
||||
"distanceMoved",
|
||||
"movementTimeMs",
|
||||
"stackCompliancePercent",
|
||||
"threatControlPercent",
|
||||
"timeToResourceFailureMs",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
"hps",
|
||||
"dtps",
|
||||
"overhealPercent",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
] as const satisfies readonly (keyof BenchmarkRunMetrics)[]);
|
||||
|
||||
export type BenchmarkMetricKey = (typeof BENCHMARK_METRIC_KEYS)[number];
|
||||
|
||||
export interface BenchmarkDistribution {
|
||||
/** Number of validated runs with a finite observation for this metric. */
|
||||
readonly count: number;
|
||||
/** Validated runs where the metric was null or non-finite. */
|
||||
readonly nullCount: number;
|
||||
readonly median: number | null;
|
||||
readonly p10: number | null;
|
||||
readonly p90: number | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummaryRow {
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioVersion: number;
|
||||
readonly scenarioName: string;
|
||||
readonly scenarioFamily: BenchmarkScenarioFamily;
|
||||
readonly buildId: string;
|
||||
readonly source: BenchmarkRunReport["build"]["source"];
|
||||
readonly classId: BenchmarkRunReport["build"]["classId"];
|
||||
readonly secondaryClassId: BenchmarkRunReport["build"]["secondaryClassId"];
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly gearPresetId: string;
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly completedRunCount: number;
|
||||
readonly metrics: Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: BenchmarkMatrixReport["cohort"];
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly rows: readonly BenchmarkMatrixSummaryRow[];
|
||||
}
|
||||
|
||||
export const ROLE_BENCHMARK_METRICS = Object.freeze({
|
||||
damage: Object.freeze([
|
||||
"dps",
|
||||
"priorityTargetDamage",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
]),
|
||||
tank: Object.freeze([
|
||||
"dtps",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
"threatControlPercent",
|
||||
"minimumPlayerHealthPercent",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
"hps",
|
||||
"absorbsConsumed",
|
||||
"overhealPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"deaths",
|
||||
"timeToResourceFailureMs",
|
||||
]),
|
||||
} as const satisfies Readonly<Record<PartyRole, readonly BenchmarkMetricKey[]>>);
|
||||
|
||||
const MECHANIC_METRICS = Object.freeze([
|
||||
"movementInterrupts",
|
||||
"stackCompliancePercent",
|
||||
] as const satisfies readonly BenchmarkMetricKey[]);
|
||||
|
||||
const ROLE_ORDER: Readonly<Record<PartyRole, number>> = Object.freeze({
|
||||
tank: 0,
|
||||
healer: 1,
|
||||
damage: 2,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Readonly<Record<PartyRole, string>> = Object.freeze({
|
||||
tank: "Tank",
|
||||
healer: "Healer",
|
||||
damage: "Damage",
|
||||
});
|
||||
|
||||
const METRIC_LABELS: Readonly<Record<BenchmarkMetricKey, string>> = Object.freeze({
|
||||
rawDamage: "Raw damage",
|
||||
effectiveDamage: "Effective damage",
|
||||
priorityTargetDamage: "Priority damage",
|
||||
effectiveHealing: "Effective healing",
|
||||
rawHealing: "Raw healing",
|
||||
overheal: "Overheal",
|
||||
absorbsApplied: "Absorbs applied",
|
||||
absorbsConsumed: "Absorbs consumed",
|
||||
rawDamageTaken: "Raw damage taken",
|
||||
effectiveDamageTaken: "Effective damage taken",
|
||||
incomingAttacks: "Incoming attacks",
|
||||
avoidedAttacks: "Avoided attacks",
|
||||
deaths: "Deaths",
|
||||
minimumPlayerHealthPercent: "Minimum player health",
|
||||
minimumPartyHealthPercent: "Minimum party health",
|
||||
castsStarted: "Casts started",
|
||||
castsCompleted: "Casts completed",
|
||||
movementInterrupts: "Movement interrupts",
|
||||
invalidCasts: "Invalid casts",
|
||||
distanceMoved: "Distance moved",
|
||||
movementTimeMs: "Movement time",
|
||||
stackCompliancePercent: "Stack compliance",
|
||||
threatControlPercent: "Threat control",
|
||||
timeToResourceFailureMs: "Resource failure",
|
||||
timeToFirstDeathMs: "First death",
|
||||
dps: "DPS",
|
||||
hps: "HPS",
|
||||
dtps: "DTPS",
|
||||
overhealPercent: "Overheal",
|
||||
mitigationPercent: "Mitigation",
|
||||
avoidancePercent: "Avoidance",
|
||||
});
|
||||
|
||||
type BenchmarkReportInput = BenchmarkMatrixReport | BenchmarkMatrixSummary;
|
||||
|
||||
interface MutableAggregationGroup {
|
||||
readonly representative: BenchmarkRunReport;
|
||||
readonly runs: BenchmarkRunReport[];
|
||||
}
|
||||
|
||||
function finiteValues(values: readonly (number | null)[]): number[] {
|
||||
return values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a linearly interpolated quantile after dropping null and non-finite values.
|
||||
*/
|
||||
export function benchmarkQuantile(
|
||||
values: readonly (number | null)[],
|
||||
quantile: number,
|
||||
): number | null {
|
||||
if (quantile < 0 || quantile > 1 || !Number.isFinite(quantile)) {
|
||||
throw new RangeError(`Benchmark quantile must be between 0 and 1; received ${quantile}.`);
|
||||
}
|
||||
|
||||
const sorted = finiteValues(values).sort((left, right) => left - right);
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
|
||||
const rank = (sorted.length - 1) * quantile;
|
||||
const lowerIndex = Math.floor(rank);
|
||||
const upperIndex = Math.ceil(rank);
|
||||
const lower = sorted[lowerIndex];
|
||||
const upper = sorted[upperIndex];
|
||||
if (lowerIndex === upperIndex) return lower;
|
||||
return lower + ((upper - lower) * (rank - lowerIndex));
|
||||
}
|
||||
|
||||
function summarizeMetric(
|
||||
runs: readonly BenchmarkRunReport[],
|
||||
metric: BenchmarkMetricKey,
|
||||
): BenchmarkDistribution {
|
||||
const values = runs.map((run) => run.metrics[metric]);
|
||||
const observed = finiteValues(values);
|
||||
return {
|
||||
count: observed.length,
|
||||
nullCount: values.length - observed.length,
|
||||
median: benchmarkQuantile(observed, 0.5),
|
||||
p10: benchmarkQuantile(observed, 0.1),
|
||||
p90: benchmarkQuantile(observed, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregationKey(run: BenchmarkRunReport): string {
|
||||
return JSON.stringify([
|
||||
run.scenario.id,
|
||||
run.scenario.version,
|
||||
run.build.id,
|
||||
run.build.source,
|
||||
run.build.classId,
|
||||
run.build.secondaryClassId ?? null,
|
||||
run.build.role,
|
||||
run.build.level,
|
||||
run.build.orientation,
|
||||
run.build.gearPresetId,
|
||||
]);
|
||||
}
|
||||
|
||||
function compareSummaryRows(
|
||||
left: BenchmarkMatrixSummaryRow,
|
||||
right: BenchmarkMatrixSummaryRow,
|
||||
): number {
|
||||
return left.scenarioId.localeCompare(right.scenarioId)
|
||||
|| left.scenarioVersion - right.scenarioVersion
|
||||
|| ROLE_ORDER[left.role] - ROLE_ORDER[right.role]
|
||||
|| left.source.localeCompare(right.source)
|
||||
|| left.classId.localeCompare(right.classId)
|
||||
|| (left.secondaryClassId ?? "").localeCompare(right.secondaryClassId ?? "")
|
||||
|| left.buildId.localeCompare(right.buildId);
|
||||
}
|
||||
|
||||
export function aggregateBenchmarkMatrix(report: BenchmarkMatrixReport): BenchmarkMatrixSummary {
|
||||
const groups = new Map<string, MutableAggregationGroup>();
|
||||
|
||||
for (const run of report.runs) {
|
||||
const key = aggregationKey(run);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.runs.push(run);
|
||||
} else {
|
||||
groups.set(key, { representative: run, runs: [run] });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...groups.values()].map(({ representative, runs }) => {
|
||||
const validRuns = runs.filter((run) => run.validation.valid);
|
||||
const metrics = Object.fromEntries(BENCHMARK_METRIC_KEYS.map((metric) => (
|
||||
[metric, summarizeMetric(validRuns, metric)]
|
||||
))) as unknown as Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
|
||||
return {
|
||||
scenarioId: representative.scenario.id,
|
||||
scenarioVersion: representative.scenario.version,
|
||||
scenarioName: representative.scenario.name,
|
||||
scenarioFamily: representative.scenario.family,
|
||||
buildId: representative.build.id,
|
||||
source: representative.build.source,
|
||||
classId: representative.build.classId,
|
||||
secondaryClassId: representative.build.secondaryClassId ?? null,
|
||||
role: representative.build.role,
|
||||
level: representative.build.level,
|
||||
orientation: representative.build.orientation,
|
||||
gearPresetId: representative.build.gearPresetId,
|
||||
runCount: runs.length,
|
||||
validRunCount: validRuns.length,
|
||||
invalidRunCount: runs.length - validRuns.length,
|
||||
completedRunCount: runs.filter((run) => run.completed).length,
|
||||
metrics,
|
||||
} satisfies BenchmarkMatrixSummaryRow;
|
||||
}).sort(compareSummaryRows);
|
||||
|
||||
const validRunCount = report.runs.filter((run) => run.validation.valid).length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: report.generatedAt,
|
||||
appVersion: report.appVersion,
|
||||
cohort: report.cohort,
|
||||
runCount: report.runs.length,
|
||||
validRunCount,
|
||||
invalidRunCount: report.runs.length - validRunCount,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function asSummary(input: BenchmarkReportInput): BenchmarkMatrixSummary {
|
||||
return "rows" in input ? input : aggregateBenchmarkMatrix(input);
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | null): string {
|
||||
if (value === null) return "";
|
||||
const text = String(value);
|
||||
return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function csvLine(values: readonly (string | number | null)[]): string {
|
||||
return values.map(csvCell).join(",");
|
||||
}
|
||||
|
||||
/** Produces one CSV row per scenario/build/metric, retaining the complete metric vector. */
|
||||
export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [csvLine([
|
||||
"schema_version",
|
||||
"generated_at",
|
||||
"app_version",
|
||||
"cohort_level",
|
||||
"cohort_gear_preset_id",
|
||||
"seeds",
|
||||
"duration_scale",
|
||||
"policy_id",
|
||||
"scenario_id",
|
||||
"scenario_version",
|
||||
"scenario_name",
|
||||
"scenario_family",
|
||||
"build_id",
|
||||
"source",
|
||||
"class_id",
|
||||
"secondary_class_id",
|
||||
"role",
|
||||
"build_level",
|
||||
"orientation",
|
||||
"gear_preset_id",
|
||||
"run_count",
|
||||
"valid_run_count",
|
||||
"invalid_run_count",
|
||||
"completed_run_count",
|
||||
"metric",
|
||||
"observed_count",
|
||||
"null_count",
|
||||
"p10",
|
||||
"median",
|
||||
"p90",
|
||||
])];
|
||||
|
||||
for (const row of summary.rows) {
|
||||
for (const metric of BENCHMARK_METRIC_KEYS) {
|
||||
const distribution = row.metrics[metric];
|
||||
lines.push(csvLine([
|
||||
summary.schemaVersion,
|
||||
summary.generatedAt,
|
||||
summary.appVersion,
|
||||
summary.cohort.level,
|
||||
summary.cohort.gearPresetId,
|
||||
summary.cohort.seeds.join(";"),
|
||||
summary.cohort.durationScale,
|
||||
summary.cohort.policyId,
|
||||
row.scenarioId,
|
||||
row.scenarioVersion,
|
||||
row.scenarioName,
|
||||
row.scenarioFamily,
|
||||
row.buildId,
|
||||
row.source,
|
||||
row.classId,
|
||||
row.secondaryClassId ?? null,
|
||||
row.role,
|
||||
row.level,
|
||||
row.orientation,
|
||||
row.gearPresetId,
|
||||
row.runCount,
|
||||
row.validRunCount,
|
||||
row.invalidRunCount,
|
||||
row.completedRunCount,
|
||||
metric,
|
||||
distribution.count,
|
||||
distribution.nullCount,
|
||||
distribution.p10,
|
||||
distribution.median,
|
||||
distribution.p90,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function markdownCell(value: string): string {
|
||||
return value.replaceAll("|", "\\|").replace(/[\r\n]+/gu, " ");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) return value.toLocaleString("en-US");
|
||||
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: BenchmarkMetricKey, value: number): string {
|
||||
if (metric.endsWith("Percent")) return `${formatNumber(value)}%`;
|
||||
if (metric.endsWith("Ms")) return `${formatNumber(value / 1_000)}s`;
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function formatDistribution(metric: BenchmarkMetricKey, value: BenchmarkDistribution): string {
|
||||
if (value.median === null || value.p10 === null || value.p90 === null) {
|
||||
return value.nullCount > 0 ? `not observed (0/${value.nullCount})` : "no valid runs";
|
||||
}
|
||||
|
||||
const range = `${formatMetricValue(metric, value.p10)}–${formatMetricValue(metric, value.p90)}`;
|
||||
const observation = value.nullCount > 0
|
||||
? `; observed ${value.count}/${value.count + value.nullCount}`
|
||||
: "";
|
||||
return `${formatMetricValue(metric, value.median)} [${range}]${observation}`;
|
||||
}
|
||||
|
||||
function roleMetricsFor(row: BenchmarkMatrixSummaryRow): readonly BenchmarkMetricKey[] {
|
||||
if (row.scenarioFamily !== "mechanics") return ROLE_BENCHMARK_METRICS[row.role];
|
||||
return [...ROLE_BENCHMARK_METRICS[row.role], ...MECHANIC_METRICS];
|
||||
}
|
||||
|
||||
function formatRoleVector(row: BenchmarkMatrixSummaryRow): string {
|
||||
return roleMetricsFor(row).map((metric) => (
|
||||
`${METRIC_LABELS[metric]}: ${formatDistribution(metric, row.metrics[metric])}`
|
||||
)).join("; ");
|
||||
}
|
||||
|
||||
/** Renders a compact, role-separated view without collapsing metrics into a score. */
|
||||
export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [
|
||||
"# Balance benchmark summary",
|
||||
"",
|
||||
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
||||
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
||||
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
||||
`Policy: \`${summary.cohort.policyId}\`.`,
|
||||
"",
|
||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||
+ "Values are median [p10–p90].",
|
||||
];
|
||||
|
||||
for (const role of ["tank", "healer", "damage"] as const) {
|
||||
const roleRows = summary.rows.filter((row) => row.role === role);
|
||||
if (roleRows.length === 0) continue;
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`## ${ROLE_LABELS[role]}`,
|
||||
"",
|
||||
"| Scenario | Build | Runs | Role vector |",
|
||||
"| --- | --- | ---: | --- |",
|
||||
);
|
||||
for (const row of roleRows) {
|
||||
const scenario = `${row.scenarioName} (v${row.scenarioVersion})`;
|
||||
const secondary = row.secondaryClassId ? ` / ${row.secondaryClassId}` : "";
|
||||
const build = `${row.buildId} — ${row.source}:${row.classId}${secondary}`;
|
||||
const runs = `${row.validRunCount}/${row.runCount}`;
|
||||
lines.push(
|
||||
`| ${markdownCell(scenario)} | ${markdownCell(build)} | ${runs} | `
|
||||
+ `${markdownCell(formatRoleVector(row))} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.rows.length === 0) {
|
||||
lines.push("", "No benchmark runs were supplied.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Compare builds only within the same scenario version and cohort. "
|
||||
+ "Role metrics remain separate; this report does not calculate a composite score.",
|
||||
);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import { runCombatBenchmark } from "./benchmarkRunner";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
describe("combat benchmark runner", () => {
|
||||
it("replays the authoritative player path deterministically", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["mage"]);
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
const options = { seed: 42, durationScale: 0.12 } as const;
|
||||
|
||||
const first = await runCombatBenchmark(build, scenario, options);
|
||||
const second = await runCombatBenchmark(build, scenario, options);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(first.metrics.effectiveDamage).toBeGreaterThan(0);
|
||||
expect(first.metrics.priorityTargetDamage).toBe(first.metrics.effectiveDamage);
|
||||
expect(first.metrics.castsCompleted).toBeGreaterThan(0);
|
||||
expect(first.eventCounts.damage).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("treats avoided reference attacks as valid tank pressure", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["guardian"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidedAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidancePercent).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps benchmark bosses attacking through player hard control", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["starcaller"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
|
||||
import type { AzerothCoreMechanic } from "../azerothCoreReference";
|
||||
import { installCombatClock } from "../combatClock";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import type { EnemyAttackDefinition } from "../enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
getPartyRuntimePosition,
|
||||
registerPartyRuntimePosition,
|
||||
} from "../partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { CombatPosition } from "../mobRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BenchmarkRecorder } from "./benchmarkRecorder";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkRunOptions,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
|
||||
"charm",
|
||||
"disarm",
|
||||
"disorient",
|
||||
"fear",
|
||||
"freeze",
|
||||
"grip",
|
||||
"horror",
|
||||
"knockout",
|
||||
"pacify",
|
||||
"polymorph",
|
||||
"root",
|
||||
"sap",
|
||||
"shackle",
|
||||
"silence",
|
||||
"sleep",
|
||||
"snare",
|
||||
"stun",
|
||||
"turn",
|
||||
]);
|
||||
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
|
||||
"blind",
|
||||
"charm",
|
||||
"confuse",
|
||||
"disarm",
|
||||
"fear",
|
||||
"freeze",
|
||||
"knockdown",
|
||||
"pacify",
|
||||
"silence",
|
||||
"sleep",
|
||||
"stun",
|
||||
]);
|
||||
|
||||
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
|
||||
const requested = scenario.durationMs * Math.max(0.001, scale);
|
||||
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
|
||||
}
|
||||
|
||||
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
|
||||
if (layout === "spread") {
|
||||
const spread: readonly CombatPosition[] = [
|
||||
[0, 0, 1],
|
||||
[-12, 0, -3],
|
||||
[12, 0, -3],
|
||||
[0, 0, -12],
|
||||
];
|
||||
return spread.slice(0, count);
|
||||
}
|
||||
if (layout === "stacked") {
|
||||
const stacked: readonly CombatPosition[] = [
|
||||
[-1, 0, 0.5],
|
||||
[1, 0, 0.5],
|
||||
[-0.5, 0, -1],
|
||||
[0.5, 0, -1],
|
||||
];
|
||||
return stacked.slice(0, count);
|
||||
}
|
||||
const melee: readonly CombatPosition[] = [
|
||||
[-1.5, 0, 1],
|
||||
[1.5, 0, 1],
|
||||
[-2.5, 0, -1],
|
||||
[2.5, 0, -1],
|
||||
];
|
||||
return melee.slice(0, count);
|
||||
}
|
||||
|
||||
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
|
||||
if (index === 0 || count <= 1) return [0, 0, 2.5];
|
||||
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
|
||||
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
|
||||
}
|
||||
|
||||
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
|
||||
return scenario.id === "damage-aoe-eight" ? 6 : 2;
|
||||
}
|
||||
|
||||
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
|
||||
function restoreBenchmarkMobPositions(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
mobIds: readonly string[],
|
||||
): void {
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => setMobPosition(
|
||||
id,
|
||||
mobPosition(index, mobIds.length, ringRadius),
|
||||
));
|
||||
}
|
||||
|
||||
function referenceAttackDamage(level: number): number {
|
||||
return 12 + level * 2;
|
||||
}
|
||||
|
||||
function enemyAttacks(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
level: number,
|
||||
referenceHealth: number,
|
||||
): readonly EnemyAttackDefinition[] {
|
||||
const baseDamage = referenceAttackDamage(level);
|
||||
return scenario.attacks.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
|
||||
target: profile.target,
|
||||
school: profile.school,
|
||||
animation: profile.school === "physical" ? "attack" : "cast",
|
||||
range: profile.target === "primary" ? 5.5 : 45,
|
||||
cooldownMs: profile.intervalMs,
|
||||
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
|
||||
...(profile.radius === undefined ? {} : { radius: profile.radius }),
|
||||
}));
|
||||
}
|
||||
|
||||
function movementPosition(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
elapsedMs: number,
|
||||
): { readonly position: CombatPosition; readonly moving: boolean } {
|
||||
const movement = scenario.movement;
|
||||
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
|
||||
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
|
||||
if (cycle < movement.outboundMs) {
|
||||
return {
|
||||
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
|
||||
moving: true,
|
||||
};
|
||||
}
|
||||
const returnStart = movement.outboundMs;
|
||||
if (cycle < returnStart + movement.returnMs) {
|
||||
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
|
||||
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
|
||||
}
|
||||
return { position: [0, 0, 0], moving: false };
|
||||
}
|
||||
|
||||
function distance(left: CombatPosition, right: CombatPosition): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
|
||||
if (scenario.partyLayout !== "stacked") return null;
|
||||
const combat = useCombatStore.getState();
|
||||
return usePartyStore.getState().members
|
||||
.filter((member) => member.health > 0)
|
||||
.every((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position !== null && distance(combat.playerPosition, position) <= 5;
|
||||
});
|
||||
}
|
||||
|
||||
function primaryThreatSource(role: PartyRole): ThreatSource {
|
||||
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
return {
|
||||
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
|
||||
role: "tank",
|
||||
abilityId: "benchmark-pull",
|
||||
};
|
||||
}
|
||||
|
||||
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
|
||||
const source = primaryThreatSource(role);
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
|
||||
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
|
||||
for (const member of damageMembers) {
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(
|
||||
id,
|
||||
Math.max(1, Math.round(member.level * 0.9)),
|
||||
now,
|
||||
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
|
||||
"physical",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceExecuteWindow(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
priorityTargetId: string,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[priorityTargetId];
|
||||
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
|
||||
return {
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
|
||||
function clearBenchmarkControlStatuses(): void {
|
||||
useCombatStore.setState((state) => {
|
||||
let changed = false;
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
|
||||
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
|
||||
if (statuses.length !== mob.statuses.length) changed = true;
|
||||
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
|
||||
}));
|
||||
return changed ? { mobs } : state;
|
||||
});
|
||||
}
|
||||
|
||||
function anyInjuredBelow(percent: number): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
|
||||
return usePartyStore.getState().members.some((member) => (
|
||||
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
|
||||
));
|
||||
}
|
||||
|
||||
/** Runs one build/scenario pair against the real authoritative player combat path. */
|
||||
export async function runCombatBenchmark(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
options: BenchmarkRunOptions = {},
|
||||
): Promise<BenchmarkRunReport> {
|
||||
if (!scenario.roles.includes(build.role)) {
|
||||
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
|
||||
}
|
||||
const seed = Math.trunc(options.seed ?? 1);
|
||||
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
|
||||
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
|
||||
await preloadAbilityCatalogsForClasses(classIds);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = BENCHMARK_CLOCK_ORIGIN;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let recorder: BenchmarkRecorder | null = null;
|
||||
let simulatedDurationMs = 0;
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
equipment: build.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([0, 0, 0]);
|
||||
const initializedCombat = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty(
|
||||
`benchmark:${build.id}:${scenario.id}:${seed}`,
|
||||
build.level,
|
||||
initializedCombat.maxHealth,
|
||||
build.role,
|
||||
5,
|
||||
build.source,
|
||||
);
|
||||
const party = usePartyStore.getState();
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
|
||||
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
|
||||
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => {
|
||||
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
|
||||
boss: index === 0,
|
||||
level: build.level,
|
||||
maxHealth: BENCHMARK_MOB_HEALTH,
|
||||
xpReward: 0,
|
||||
hasLoot: false,
|
||||
moveSpeed: 0,
|
||||
aggroRange: 45,
|
||||
leashRange: 60,
|
||||
attacks,
|
||||
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
|
||||
});
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
seedThreat(mobIds, build.role, now);
|
||||
useCombatStore.getState().selectMob(mobIds[0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
|
||||
const controller = new BenchmarkController();
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs: 0,
|
||||
combat: useCombatStore.getState(),
|
||||
party: usePartyStore.getState(),
|
||||
movedDistance: 0,
|
||||
moving: false,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: 0,
|
||||
totalThreatMobs: 0,
|
||||
});
|
||||
|
||||
let resourceFailureDecisions = 0;
|
||||
let previousPosition: CombatPosition = [0, 0, 0];
|
||||
let nextThreatCompetitionAt = 2_000;
|
||||
while (simulatedDurationMs < durationMs) {
|
||||
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
|
||||
simulatedDurationMs += deltaMs;
|
||||
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
|
||||
const movement = movementPosition(scenario, simulatedDurationMs);
|
||||
useCombatStore.getState().setPlayerPosition(movement.position);
|
||||
const movedDistance = distance(previousPosition, movement.position);
|
||||
previousPosition = movement.position;
|
||||
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
useCombatStore.getState().tick(deltaMs / 1_000, now);
|
||||
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
if (scenario.attacks.length) {
|
||||
clearBenchmarkControlStatuses();
|
||||
useCombatStore.getState().advanceMobCombat(now, movement.position);
|
||||
}
|
||||
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
|
||||
applyTankThreatCompetition(mobIds, now);
|
||||
nextThreatCompetitionAt += 2_000;
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const decision = controller.act(now, build.role, scenario, movement.moving);
|
||||
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
|
||||
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
|
||||
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
|
||||
? 0
|
||||
: resourceFailureDecisions + 1;
|
||||
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
|
||||
}
|
||||
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const currentParty = usePartyStore.getState();
|
||||
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs,
|
||||
combat: currentCombat,
|
||||
party: currentParty,
|
||||
movedDistance,
|
||||
moving: movement.moving,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: build.role === "tank"
|
||||
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
|
||||
: 0,
|
||||
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
|
||||
});
|
||||
|
||||
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
|
||||
const partyDied = currentParty.members.some((member) => member.health <= 0)
|
||||
&& (scenario.stopOnPartyDeath || build.role === "healer");
|
||||
const resourceFailed = scenario.resourceFailureGcds !== undefined
|
||||
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
|
||||
if (playerDied || partyDied || resourceFailed) {
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.finish(simulatedDurationMs, completed);
|
||||
} catch (error: unknown) {
|
||||
if (!recorder) throw error;
|
||||
const partial = recorder.finish(simulatedDurationMs, false);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Object.freeze({
|
||||
...partial,
|
||||
completed: false,
|
||||
validation: Object.freeze({
|
||||
valid: false,
|
||||
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
recorder?.dispose();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects only scenario/build combinations that share a declared role. */
|
||||
export function compatibleBenchmarkPairs(
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
scenarios: readonly BenchmarkScenarioDefinition[],
|
||||
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
|
||||
return builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => [build, scenario] as const));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import { BENCHMARK_SCENARIOS, benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
const EXPECTED_DURATIONS_MS: Readonly<Record<string, number>> = Object.freeze({
|
||||
"damage-single-burst": 30_000,
|
||||
"damage-single-sustained": 180_000,
|
||||
"damage-cleave-three": 90_000,
|
||||
"damage-aoe-eight": 60_000,
|
||||
"tank-mixed-boss": 180_000,
|
||||
"tank-multi-target": 120_000,
|
||||
"heal-single-target": 90_000,
|
||||
"heal-spread-party": 180_000,
|
||||
"heal-stacked-party": 90_000,
|
||||
"heal-resource-endurance": 600_000,
|
||||
"mechanic-movement-boss": 180_000,
|
||||
"mechanic-stack-boss": 180_000,
|
||||
});
|
||||
|
||||
describe("benchmark scenario catalog", () => {
|
||||
it("uses unique stable ids and positive integer versions", () => {
|
||||
const ids = BENCHMARK_SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
expect(ids).toHaveLength(Object.keys(EXPECTED_DURATIONS_MS).length);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect([...ids].sort()).toEqual(Object.keys(EXPECTED_DURATIONS_MS).sort());
|
||||
expect(BENCHMARK_SCENARIOS.every(
|
||||
(scenario) => Number.isInteger(scenario.version) && scenario.version >= 1,
|
||||
)).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => scenario.version === 1)).toBe(true);
|
||||
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(benchmarkScenarioById(scenario.id)).toBe(scenario);
|
||||
}
|
||||
expect(benchmarkScenarioById("missing-scenario")).toBeNull();
|
||||
});
|
||||
|
||||
it("covers every combat role and keeps role-specific families scoped", () => {
|
||||
const expectedRoles: readonly PartyRole[] = ["tank", "healer", "damage"];
|
||||
const coveredRoles = new Set(BENCHMARK_SCENARIOS.flatMap((scenario) => scenario.roles));
|
||||
|
||||
expect([...coveredRoles].sort()).toEqual([...expectedRoles].sort());
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => (
|
||||
scenario.roles.length > 0
|
||||
&& scenario.roles.every((role) => expectedRoles.includes(role))
|
||||
))).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "damage")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "damage"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "tanking")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "tank"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => (
|
||||
scenario.family === "healing" || scenario.family === "endurance"
|
||||
)).every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "healer"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "mechanics")
|
||||
.every((scenario) => expectedRoles.every((role) => scenario.roles.includes(role))))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("preserves exact durations on an integral fixed-step timeline", () => {
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(scenario.durationMs).toBe(EXPECTED_DURATIONS_MS[scenario.id]);
|
||||
expect(Number.isInteger(scenario.durationMs)).toBe(true);
|
||||
expect(Number.isInteger(scenario.tickMs)).toBe(true);
|
||||
expect(scenario.durationMs).toBeGreaterThan(0);
|
||||
expect(scenario.tickMs).toBeGreaterThan(0);
|
||||
expect(scenario.durationMs % scenario.tickMs).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { BenchmarkEnemyAttackProfile, BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
const roles = ["tank", "healer", "damage"] as const;
|
||||
|
||||
const attack = (
|
||||
id: string,
|
||||
name: string,
|
||||
target: BenchmarkEnemyAttackProfile["target"],
|
||||
intervalMs: number,
|
||||
damageMultiplier: number,
|
||||
options: Pick<BenchmarkEnemyAttackProfile, "school" | "radius"> = { school: "physical" },
|
||||
): BenchmarkEnemyAttackProfile => Object.freeze({
|
||||
id,
|
||||
name,
|
||||
target,
|
||||
intervalMs,
|
||||
damageMultiplier,
|
||||
school: options.school,
|
||||
...(options.radius === undefined ? {} : { radius: options.radius }),
|
||||
});
|
||||
|
||||
/** Versioned, fixed-input contracts used by the automated balance matrix. */
|
||||
export const BENCHMARK_SCENARIOS: readonly BenchmarkScenarioDefinition[] = Object.freeze([
|
||||
{
|
||||
id: "damage-single-burst",
|
||||
version: 1,
|
||||
name: "Single-target burst",
|
||||
description: "Thirty seconds on an immortal boss with all cooldowns ready.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 30_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-single-sustained",
|
||||
version: 1,
|
||||
name: "Single-target sustained",
|
||||
description: "Three minutes on an immortal boss, including a scripted execute window.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-cleave-three",
|
||||
version: 1,
|
||||
name: "Three-target cleave",
|
||||
description: "Three immortal targets packed within five yards of the priority target.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 3,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-aoe-eight",
|
||||
version: 1,
|
||||
name: "Eight-target AoE",
|
||||
description: "A priority target surrounded by seven immortal targets on a six-yard ring.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 60_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 8,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "tank-mixed-boss",
|
||||
version: 1,
|
||||
name: "Mixed boss tanking",
|
||||
description: "Physical swings plus a periodic magical tank buster.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-swing", "Reference Swing", "primary", 2_000, 0.08),
|
||||
attack("benchmark-buster", "Arcane Buster", "primary", 20_000, 0.42, { school: "arcane" }),
|
||||
],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "tank-multi-target",
|
||||
version: 1,
|
||||
name: "Multi-target tanking",
|
||||
description: "Five enemies test AoE threat, mitigation, and survival under many swings.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 120_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 5,
|
||||
partyLayout: "melee",
|
||||
attacks: [attack("benchmark-add-swing", "Add Swing", "primary", 2_000, 0.022)],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-single-target",
|
||||
version: 1,
|
||||
name: "Single-target healing",
|
||||
description: "A reference tank takes sustained swings and fifteen-second burst hits.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-tank-pressure", "Tank Pressure", "primary", 2_000, 0.12),
|
||||
attack("benchmark-tank-spike", "Tank Spike", "primary", 15_000, 0.55, { school: "shadow" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-spread-party",
|
||||
version: 1,
|
||||
name: "Spread party healing",
|
||||
description: "Party members spread apart while raid pulses and deterministic spot damage land.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-spread-tank", "Tank Pressure", "primary", 2_000, 0.07),
|
||||
attack("benchmark-spread-pulse", "Spread Pulse", "nearby-party", 6_000, 0.12, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-spread-spot", "Spot Damage", "random-party", 12_000, 0.32, { school: "fire" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-stacked-party",
|
||||
version: 1,
|
||||
name: "Stacked party healing",
|
||||
description: "The group stacks within five yards for rapid party-wide pulses.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-tank", "Tank Pressure", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-pulse", "Stack Pulse", "nearby-party", 3_000, 0.18, { school: "arcane", radius: 8 }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-resource-endurance",
|
||||
version: 1,
|
||||
name: "Healing resource endurance",
|
||||
description: "Up to ten minutes of sustained tank and party pressure.",
|
||||
family: "endurance",
|
||||
roles: ["healer"],
|
||||
durationMs: 600_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-endurance-tank", "Endurance Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-endurance-party", "Endurance Pulse", "nearby-party", 4_000, 0.045, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-endurance-spot", "Endurance Spot Hit", "random-party", 15_000, 0.25, { school: "frost" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
resourceFailureGcds: 3,
|
||||
},
|
||||
{
|
||||
id: "mechanic-movement-boss",
|
||||
version: 1,
|
||||
name: "Movement boss",
|
||||
description: "Repeated outbound and return movement interrupts stationary casts and reduces uptime.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-movement-swing", "Movement Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-movement-pulse", "Movement Pulse", "nearby-party", 8_000, 0.06, { school: "fire", radius: 40 }),
|
||||
],
|
||||
movement: { startsAtMs: 15_000, periodMs: 20_000, outboundMs: 4_000, returnMs: 2_000, distance: 8 },
|
||||
},
|
||||
{
|
||||
id: "mechanic-stack-boss",
|
||||
version: 1,
|
||||
name: "Stack boss",
|
||||
description: "The party remains tightly grouped so proximity healing and split-pressure tools are exercised.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-swing", "Stack Boss Swing", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-mechanic", "Shared Burden", "nearby-party", 6_000, 0.15, { school: "shadow", radius: 8 }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function benchmarkScenarioById(id: string): BenchmarkScenarioDefinition | null {
|
||||
return BENCHMARK_SCENARIOS.find((scenario) => scenario.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
|
||||
export type BenchmarkScenarioFamily = "damage" | "tanking" | "healing" | "mechanics" | "endurance";
|
||||
export type BenchmarkPartyLayout = "none" | "melee" | "spread" | "stacked";
|
||||
|
||||
export interface BenchmarkMovementPattern {
|
||||
readonly startsAtMs: number;
|
||||
readonly periodMs: number;
|
||||
readonly outboundMs: number;
|
||||
readonly returnMs: number;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkEnemyAttackProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly target: "primary" | "random-party" | "nearby-party";
|
||||
readonly school: "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy";
|
||||
readonly intervalMs: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkScenarioDefinition {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly family: BenchmarkScenarioFamily;
|
||||
readonly roles: readonly PartyRole[];
|
||||
readonly durationMs: number;
|
||||
readonly tickMs: number;
|
||||
readonly hostileTargets: number;
|
||||
readonly partyLayout: BenchmarkPartyLayout;
|
||||
readonly attacks: readonly BenchmarkEnemyAttackProfile[];
|
||||
readonly movement?: BenchmarkMovementPattern;
|
||||
readonly stopOnPlayerDeath?: boolean;
|
||||
readonly stopOnPartyDeath?: boolean;
|
||||
readonly resourceFailureGcds?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
readonly gearPresetId: string;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunOptions {
|
||||
readonly seed?: number;
|
||||
readonly durationScale?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkAbilityMetrics {
|
||||
readonly abilityId: string;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly interrupts: number;
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResourceMetrics {
|
||||
readonly id: string;
|
||||
readonly starting: number;
|
||||
readonly ending: number;
|
||||
readonly minimum: number;
|
||||
readonly maximum: number;
|
||||
readonly timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunMetrics {
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly priorityTargetDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly rawHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
readonly absorbsConsumed: number;
|
||||
readonly rawDamageTaken: number;
|
||||
readonly effectiveDamageTaken: number;
|
||||
readonly incomingAttacks: number;
|
||||
readonly avoidedAttacks: number;
|
||||
readonly deaths: number;
|
||||
readonly minimumPlayerHealthPercent: number;
|
||||
readonly minimumPartyHealthPercent: number;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly movementInterrupts: number;
|
||||
readonly invalidCasts: number;
|
||||
readonly distanceMoved: number;
|
||||
readonly movementTimeMs: number;
|
||||
readonly stackCompliancePercent: number | null;
|
||||
readonly threatControlPercent: number | null;
|
||||
readonly timeToResourceFailureMs: number | null;
|
||||
readonly timeToFirstDeathMs: number | null;
|
||||
readonly dps: number;
|
||||
readonly hps: number;
|
||||
readonly dtps: number;
|
||||
readonly overhealPercent: number;
|
||||
readonly mitigationPercent: number;
|
||||
readonly avoidancePercent: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkValidation {
|
||||
readonly valid: boolean;
|
||||
readonly reasons: readonly string[];
|
||||
}
|
||||
|
||||
export interface BenchmarkRunReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly policyId: string;
|
||||
readonly seed: number;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
readonly scenario: BenchmarkScenarioDefinition;
|
||||
readonly simulatedDurationMs: number;
|
||||
readonly completed: boolean;
|
||||
readonly metrics: BenchmarkRunMetrics;
|
||||
readonly resources: readonly BenchmarkResourceMetrics[];
|
||||
readonly abilities: readonly BenchmarkAbilityMetrics[];
|
||||
readonly eventCounts: Readonly<Record<string, number>>;
|
||||
readonly validation: BenchmarkValidation;
|
||||
readonly gearStats: ItemStats;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: {
|
||||
readonly level: number;
|
||||
readonly gearPresetId: string;
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly policyId: string;
|
||||
};
|
||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||
readonly runs: readonly BenchmarkRunReport[];
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const clips = [
|
||||
{ name: "Fall (ID 40 variation 0)" },
|
||||
{ name: "JumpEnd (ID 39 variation 0)" },
|
||||
{ name: "JumpLandRun (ID 133 variation 0)" },
|
||||
{ name: "Ready1H (ID 26 variation 0)" },
|
||||
{ name: "Ready2H (ID 27 variation 0)" },
|
||||
{ name: "Ready2HL (ID 28 variation 0)" },
|
||||
{ name: "ReadyBow (ID 29 variation 0)" },
|
||||
{ name: "HoldBow (ID 109 variation 0)" },
|
||||
];
|
||||
|
||||
describe("WoW character animation selection", () => {
|
||||
@@ -43,6 +48,31 @@ describe("WoW character animation selection", () => {
|
||||
expect(selectCharacterBaseClip(clips, "run")).toBe(clips[2]);
|
||||
});
|
||||
|
||||
it("uses the native ready pose for the equipped weapon stance", () => {
|
||||
expect(selectCharacterBaseClip(clips, "idle", "one-handed")).toBe(clips[15]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed")).toBe(clips[16]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed-left")).toBe(clips[17]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "bow")).toBe(clips[19]);
|
||||
expect(selectCharacterBaseClip(clips, "run", "bow")).toBe(clips[2]);
|
||||
expect(selectCharacterBaseClip([
|
||||
clips[0],
|
||||
clips[15],
|
||||
], "idle", "bow")).toBe(clips[15]);
|
||||
|
||||
const runewakerClips = [
|
||||
{ name: "Stand - stand_idle01" },
|
||||
{ name: "Idle - 1h_idle" },
|
||||
{ name: "Idle - 2h_idle" },
|
||||
{ name: "Idle - bow_idle" },
|
||||
];
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "one-handed"))
|
||||
.toBe(runewakerClips[1]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "two-handed-left"))
|
||||
.toBe(runewakerClips[2]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "bow"))
|
||||
.toBe(runewakerClips[3]);
|
||||
});
|
||||
|
||||
it("rotates every variation of the highest-priority available ID", () => {
|
||||
expect(selectWoWAnimationVariation(clips, [16], 0)).toBe(clips[9]);
|
||||
expect(selectWoWAnimationVariation(clips, [16], 1)).toBe(clips[10]);
|
||||
|
||||
@@ -32,6 +32,12 @@ type RunewakerCombatMotion = "melee" | "bow" | "spell";
|
||||
|
||||
export type CharacterBaseMotion = "idle" | "walk" | "run";
|
||||
|
||||
export type CharacterWeaponStance =
|
||||
| "bow"
|
||||
| "one-handed"
|
||||
| "two-handed"
|
||||
| "two-handed-left";
|
||||
|
||||
export interface CharacterClipSelection<TClip extends NamedAnimationClip> {
|
||||
readonly clip: TClip;
|
||||
readonly loop: boolean;
|
||||
@@ -108,7 +114,21 @@ export function selectWoWAnimationVariation<TClip extends NamedAnimationClip>(
|
||||
export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
|
||||
clips: readonly TClip[],
|
||||
motion: CharacterBaseMotion,
|
||||
weaponStance?: CharacterWeaponStance,
|
||||
): TClip | undefined {
|
||||
if (motion === "idle" && weaponStance) {
|
||||
const readyIds = weaponStance === "bow"
|
||||
? [109, 29, 26]
|
||||
: weaponStance === "one-handed"
|
||||
? [26]
|
||||
: weaponStance === "two-handed-left" ? [28, 27] : [27, 28];
|
||||
const runewakerIdle = weaponStance === "bow"
|
||||
? "idle - bow_idle"
|
||||
: weaponStance === "one-handed" ? "idle - 1h_idle" : "idle - 2h_idle";
|
||||
const ready = selectWoWAnimationVariation(clips, readyIds)
|
||||
?? clips.find((clip) => clip.name.toLowerCase().startsWith(runewakerIdle));
|
||||
if (ready) return ready;
|
||||
}
|
||||
const ids = motion === "run" ? [5, 4, 0] : motion === "walk" ? [4, 5, 0] : [0, 4, 5];
|
||||
return selectWoWAnimationVariation(clips, ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
type CombatPresentationEvent,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
playMasterAudioCue,
|
||||
} from "./combatAudio";
|
||||
|
||||
const fixtureEvent: CombatPresentationEvent = {
|
||||
id: 1,
|
||||
sessionId: 1,
|
||||
occurredAt: 1,
|
||||
phase: "impact",
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
targetActorId: "enemy",
|
||||
abilityId: "fixture",
|
||||
name: "Fixture",
|
||||
source: "fallback",
|
||||
school: "holy",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
await expect(playMasterAudioCue("/fixture.wav")).resolves.toBeNull();
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicitly enabled positive-volume output", () => {
|
||||
expect(masterAudioOutputEnabled(0)).toBe(false);
|
||||
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not initialize audio for a zero-volume actor group", () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
configureCombatAudio({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
audioVolume: {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS.audioVolume,
|
||||
player: 0,
|
||||
},
|
||||
}, 0.6);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+56
-5
@@ -18,11 +18,35 @@ let context: AudioContext | null = null;
|
||||
let masterGain: GainNode | null = null;
|
||||
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
|
||||
let configuredSettings: CombatPresentationSettings | null = null;
|
||||
let configuredMasterVolume = 0.8;
|
||||
let configuredMasterVolume = 0;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
export function masterAudioOutputEnabled(masterVolume: number): boolean {
|
||||
return Number.isFinite(masterVolume) && masterVolume > 0;
|
||||
}
|
||||
|
||||
export function combatAudioOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& (["player", "party", "enemy"] as const).some((group) => (
|
||||
settings.audioEnabled[group] && settings.audioVolume[group] > 0
|
||||
));
|
||||
}
|
||||
|
||||
function combatAudioGroupOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
group: CombatActorGroup,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& settings.audioEnabled[group]
|
||||
&& settings.audioVolume[group] > 0;
|
||||
}
|
||||
|
||||
function audioContextConstructor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.AudioContext
|
||||
@@ -66,7 +90,11 @@ export function configureCombatAudio(
|
||||
configuredSettings = settings;
|
||||
configuredMasterVolume = Number.isFinite(masterVolume)
|
||||
? Math.max(0, Math.min(1, masterVolume))
|
||||
: 0.8;
|
||||
: 0;
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) {
|
||||
resetCombatAudio();
|
||||
if (context?.state === "running") void context.suspend().catch(() => undefined);
|
||||
}
|
||||
applyCombatAudioSettings();
|
||||
}
|
||||
|
||||
@@ -113,6 +141,10 @@ const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
@@ -181,9 +213,15 @@ export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (!audioContext || !buffer || !masterGain) return null;
|
||||
if (
|
||||
!masterAudioOutputEnabled(configuredMasterVolume)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !masterGain
|
||||
) return null;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -211,9 +249,19 @@ export async function playMasterAudioCue(
|
||||
|
||||
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return false;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(sound.url);
|
||||
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !groupGains
|
||||
) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -248,7 +296,10 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
return;
|
||||
}
|
||||
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface AuraProcState {
|
||||
export interface AuraDefinition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** Player-facing explanation shown by aura tooltips. */
|
||||
readonly description?: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
/** Spell artwork used by every generic aura HUD surface. */
|
||||
readonly icon?: string;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { combatNow, installCombatClock } from "./combatClock";
|
||||
|
||||
describe("combat clock", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("installs a deterministic clock and restores wall time", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(42_000);
|
||||
const restore = installCombatClock(() => 1_234);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(1_234);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(42_000);
|
||||
});
|
||||
|
||||
it("restores nested clock installations in last-in-first-out order", () => {
|
||||
const restoreOuter = installCombatClock(() => 100);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(100);
|
||||
const restoreInner = installCombatClock(() => 200);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(200);
|
||||
} finally {
|
||||
restoreInner();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(100);
|
||||
} finally {
|
||||
restoreOuter();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type CombatClock = () => number;
|
||||
|
||||
let activeCombatClock: CombatClock = () => Date.now();
|
||||
|
||||
/** Shared simulation time. Production uses wall time; deterministic tools inject a fixed-step clock. */
|
||||
export function combatNow(): number {
|
||||
return activeCombatClock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the combat clock and returns a disposer that restores the previous
|
||||
* source. Benchmark callers must run sequentially because combat stores are
|
||||
* process-wide singletons.
|
||||
*/
|
||||
export function installCombatClock(clock: CombatClock): () => void {
|
||||
const previous = activeCombatClock;
|
||||
activeCombatClock = clock;
|
||||
return () => {
|
||||
activeCombatClock = previous;
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,26 @@ export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||
|
||||
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||
|
||||
export type CombatEventListener = (event: StructuredCombatEvent) => void;
|
||||
|
||||
const combatEventListeners = new Set<CombatEventListener>();
|
||||
|
||||
/** Lossless event stream for recorders; the in-store history remains a small HUD ring. */
|
||||
export function subscribeCombatEvents(listener: CombatEventListener): () => void {
|
||||
combatEventListeners.add(listener);
|
||||
return () => combatEventListeners.delete(listener);
|
||||
}
|
||||
|
||||
function publishCombatEvent(event: StructuredCombatEvent): void {
|
||||
for (const listener of combatEventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// A metrics consumer must never interrupt authoritative combat.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appendCombatEvent(
|
||||
events: readonly StructuredCombatEvent[],
|
||||
event: CombatEventInput,
|
||||
@@ -45,5 +65,6 @@ export function appendCombatEvent(
|
||||
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||
});
|
||||
publishCombatEvent(next);
|
||||
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_COMBAT_EVENT_HISTORY,
|
||||
appendCombatEvent,
|
||||
subscribeCombatEvents,
|
||||
type CombatEventInput,
|
||||
type StructuredCombatEvent,
|
||||
} from "./combatEvents";
|
||||
|
||||
function eventInput(index: number): CombatEventInput {
|
||||
return {
|
||||
occurredAt: index * 100,
|
||||
kind: "damage",
|
||||
sourceActorId: "benchmark-source",
|
||||
targetActorId: "benchmark-target",
|
||||
abilityId: "benchmark-strike",
|
||||
school: "physical",
|
||||
rawAmount: index,
|
||||
effectiveAmount: index,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combat event subscriptions", () => {
|
||||
it("publishes every event while retaining only the bounded HUD history", () => {
|
||||
const published: StructuredCombatEvent[] = [];
|
||||
const unsubscribe = subscribeCombatEvents((event) => published.push(event));
|
||||
let history: readonly StructuredCombatEvent[] = [];
|
||||
const eventCount = MAX_COMBAT_EVENT_HISTORY + 75;
|
||||
|
||||
try {
|
||||
for (let id = 1; id <= eventCount; id += 1) {
|
||||
history = appendCombatEvent(history, eventInput(id), id);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(published).toHaveLength(eventCount);
|
||||
expect(published.map((event) => event.id)).toEqual(
|
||||
Array.from({ length: eventCount }, (_, index) => index + 1),
|
||||
);
|
||||
expect(history).toHaveLength(MAX_COMBAT_EVENT_HISTORY);
|
||||
expect(history[0]?.id).toBe(eventCount - MAX_COMBAT_EVENT_HISTORY + 1);
|
||||
expect(history.at(-1)?.id).toBe(eventCount);
|
||||
|
||||
appendCombatEvent(history, eventInput(eventCount + 1), eventCount + 1);
|
||||
expect(published).toHaveLength(eventCount);
|
||||
});
|
||||
|
||||
it("isolates authoritative recording from a failing subscriber", () => {
|
||||
const published: number[] = [];
|
||||
const unsubscribeFailing = subscribeCombatEvents(() => {
|
||||
throw new Error("metrics consumer failed");
|
||||
});
|
||||
const unsubscribeHealthy = subscribeCombatEvents((event) => published.push(event.id));
|
||||
|
||||
try {
|
||||
expect(appendCombatEvent([], eventInput(1), 1)).toHaveLength(1);
|
||||
expect(published).toEqual([1]);
|
||||
} finally {
|
||||
unsubscribeHealthy();
|
||||
unsubscribeFailing();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
interface CombatSimulationTimerState {
|
||||
readonly sharedOnline: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly companionOpen: boolean;
|
||||
readonly documentHidden: boolean;
|
||||
}
|
||||
|
||||
export function combatSimulationTimerShouldRun({
|
||||
sharedOnline,
|
||||
paused,
|
||||
companionOpen,
|
||||
documentHidden,
|
||||
}: CombatSimulationTimerState): boolean {
|
||||
return sharedOnline || (!paused && !companionOpen && !documentHidden);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
|
||||
function allocateTalentRanks(name: string, ranks: number): void {
|
||||
const talent = TALENT_NODES.find((node) => node.name === name);
|
||||
@@ -843,6 +844,7 @@ describe("combat store", () => {
|
||||
|
||||
it("allocates the first WotLK talent point at level 10 and clamps settings", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0);
|
||||
const holy = talentNodesForTree("priest-holy")[0];
|
||||
const shadow = talentNodesForTree("priest-shadow")[0];
|
||||
expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true);
|
||||
@@ -869,12 +871,44 @@ describe("combat store", () => {
|
||||
showMobAggroRanges: true,
|
||||
threatMeterPosition: { x: 0, y: 1 },
|
||||
});
|
||||
useCombatStore.getState().updateSettings({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest" });
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
settings: { masterVolume: 0.6 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
||||
});
|
||||
|
||||
it("does not notify subscribers for unchanged positions or settings", () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = useCombatStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: DEFAULT_GAMEPLAY_SETTINGS.masterVolume,
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_SETTINGS.gameplayFrameRate,
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(notifications).toBe(0);
|
||||
|
||||
useCombatStore.getState().updateSettings({ gameplayFrameRate: 30 });
|
||||
expect(notifications).toBe(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps a CoA tree ability uncastable until its exact node is purchased", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 });
|
||||
const ability = abilitiesForClass("stormbringer").find((candidate) => (
|
||||
@@ -897,6 +931,40 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 3_000).reason).not.toBe("not-learned");
|
||||
});
|
||||
|
||||
it("breaks recursive hit-proc spell cycles in the canonical Felsworn tank build", async () => {
|
||||
const build = (await loadCanonicalBenchmarkBuilds(60, ["felsworn"]))
|
||||
.find((candidate) => candidate.role === "tank")!;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: build.classId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
});
|
||||
registerTarget("felsworn-proc-dummy", [10, 0, 0], 1_000_000, 0);
|
||||
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Doomstride (Aura 42)" }),
|
||||
}));
|
||||
const result = useCombatStore.getState().castAbility(
|
||||
"felsworn-sargeron-smite",
|
||||
[0, 0, 0],
|
||||
1,
|
||||
);
|
||||
const state = useCombatStore.getState();
|
||||
const damage = 1_000_000 - state.mobs["felsworn-proc-dummy"].health;
|
||||
const damageAbilityIds = state.combatEvents
|
||||
.filter((event) => event.kind === "damage")
|
||||
.map((event) => event.abilityId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(damage).toBeGreaterThan(0);
|
||||
expect(damage).toBeLessThan(100_000);
|
||||
expect(damageAbilityIds.filter((abilityId) => abilityId === "coa-triggered-807962"))
|
||||
.toHaveLength(1);
|
||||
expect(state.auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Felwracked (Aura 4)" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("resets encounter health without erasing earned progression", () => {
|
||||
registerTarget("mob", [2, 0, 0], 1, 100);
|
||||
useCombatStore.getState().damageMob("mob", 5, 1000);
|
||||
|
||||
+117
-40
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { combatNow } from "./combatClock";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
@@ -212,6 +213,14 @@ import {
|
||||
normalizeHudPosition,
|
||||
type NormalizedHudPosition,
|
||||
} from "./hudLayout";
|
||||
import {
|
||||
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
export type MinimapRotation = "north-up" | "player-up";
|
||||
|
||||
@@ -227,6 +236,8 @@ export interface GameplaySettings {
|
||||
readonly masterVolume: number;
|
||||
readonly uiScale: number;
|
||||
readonly minimapRotation: MinimapRotation;
|
||||
readonly gameplayFrameRate: GameplayFrameRateLimit;
|
||||
readonly previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
@@ -238,9 +249,11 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
showFloatingCombatText: true,
|
||||
autoTargetNearest: true,
|
||||
enableScreenShake: true,
|
||||
masterVolume: 0.8,
|
||||
masterVolume: 0,
|
||||
uiScale: 1,
|
||||
minimapRotation: "player-up",
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
});
|
||||
|
||||
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
||||
@@ -252,6 +265,8 @@ export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
||||
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
||||
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
||||
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
||||
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
|
||||
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
|
||||
|
||||
export interface MobStatus {
|
||||
readonly kind: MobStatusKind;
|
||||
@@ -727,6 +742,8 @@ interface MutableCastWork {
|
||||
gearStats: ItemStats;
|
||||
raceId: RaceId;
|
||||
secondaryClassId: ClassId | null;
|
||||
/** Active synchronous spell/source/target keys used to break proc cycles. */
|
||||
triggeredSpellStack?: string[];
|
||||
}
|
||||
|
||||
let feedbackSequence = 0;
|
||||
@@ -1600,9 +1617,34 @@ function normalizedSettings(
|
||||
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
||||
? patch.minimapRotation
|
||||
: current.minimapRotation,
|
||||
gameplayFrameRate: normalizeGameplayFrameRateLimit(
|
||||
patch.gameplayFrameRate,
|
||||
current.gameplayFrameRate,
|
||||
),
|
||||
previewFrameRate: normalizePreviewFrameRateLimit(
|
||||
patch.previewFrameRate,
|
||||
current.previewFrameRate,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
|
||||
return left.showMobHealthBars === right.showMobHealthBars
|
||||
&& left.showMobAggroRanges === right.showMobAggroRanges
|
||||
&& left.showTargetFrame === right.showTargetFrame
|
||||
&& left.showThreatMeter === right.showThreatMeter
|
||||
&& left.threatMeterPosition.x === right.threatMeterPosition.x
|
||||
&& left.threatMeterPosition.y === right.threatMeterPosition.y
|
||||
&& left.showFloatingCombatText === right.showFloatingCombatText
|
||||
&& left.autoTargetNearest === right.autoTargetNearest
|
||||
&& left.enableScreenShake === right.enableScreenShake
|
||||
&& left.masterVolume === right.masterVolume
|
||||
&& left.uiScale === right.uiScale
|
||||
&& left.minimapRotation === right.minimapRotation
|
||||
&& left.gameplayFrameRate === right.gameplayFrameRate
|
||||
&& left.previewFrameRate === right.previewFrameRate;
|
||||
}
|
||||
|
||||
function defaultMobStats(
|
||||
playerLevel: number,
|
||||
boss: boolean,
|
||||
@@ -1829,6 +1871,7 @@ function auraDefinitionForAbility(
|
||||
): AuraDefinition {
|
||||
return {
|
||||
...definition,
|
||||
description: definition.description ?? ability.description,
|
||||
icon: definition.icon ?? ability.icon,
|
||||
durationMs,
|
||||
};
|
||||
@@ -1974,15 +2017,30 @@ function executeTriggeredAuraProcsInWork(
|
||||
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
||||
&& typeof action.data?.spellId === "number"
|
||||
) {
|
||||
const spellId = action.data.spellId;
|
||||
const targetId = triggered.event.otherId ?? triggered.targetId;
|
||||
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
|
||||
const triggeredSpellStack = work.triggeredSpellStack ?? [];
|
||||
if (
|
||||
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|
||||
|| triggeredSpellStack.includes(triggerKey)
|
||||
) continue;
|
||||
triggeredSpellStack.push(triggerKey);
|
||||
work.triggeredSpellStack = triggeredSpellStack;
|
||||
try {
|
||||
executeTriggeredSpellInWork(
|
||||
work,
|
||||
action.data.spellId,
|
||||
spellId,
|
||||
triggered.sourceId,
|
||||
triggered.event.otherId ?? triggered.targetId,
|
||||
targetId,
|
||||
now,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
triggeredSpellStack.pop();
|
||||
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
|
||||
}
|
||||
} else if (
|
||||
action.kind === "custom"
|
||||
&& action.id === "classic-status"
|
||||
@@ -2140,7 +2198,7 @@ function grantMobExperience(
|
||||
work.raceId,
|
||||
work.secondaryClassId,
|
||||
);
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, Date.now());
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
||||
}
|
||||
return scaledAward;
|
||||
}
|
||||
@@ -2696,6 +2754,8 @@ function executeTriggeredSpellInWork(
|
||||
definition: {
|
||||
id: `coa-triggered-shield:${spellId}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: effect.durationMs,
|
||||
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
|
||||
@@ -3152,7 +3212,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
resourceName: resource.name,
|
||||
abilities,
|
||||
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
||||
talentRanks,
|
||||
talentModifiers,
|
||||
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
||||
@@ -3338,7 +3398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
lootMob: (id, now = Date.now()) => {
|
||||
lootMob: (id, now = combatNow()) => {
|
||||
const mob = get().mobs[id];
|
||||
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
||||
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
||||
@@ -3562,12 +3622,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
|
||||
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
||||
));
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
|
||||
const positionUnchanged = (
|
||||
(next[0] - state.playerPosition[0]) ** 2
|
||||
+ (next[1] - state.playerPosition[1]) ** 2
|
||||
+ (next[2] - state.playerPosition[2]) ** 2
|
||||
) <= 1e-8;
|
||||
if (positionUnchanged && !movedWhileCasting) return state;
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
|
||||
return {
|
||||
playerPosition: next,
|
||||
activeCast: movedWhileCasting ? null : cast,
|
||||
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -3648,7 +3714,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
confirmGroundTarget: (position, now = Date.now()) => {
|
||||
confirmGroundTarget: (position, now = combatNow()) => {
|
||||
const pending = get().pendingGroundTarget;
|
||||
if (!pending) return emptyCastResult("invalid-destination");
|
||||
const destination = position ?? pending.position;
|
||||
@@ -3691,7 +3757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = Date.now(), legacyCompletingCast) => {
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
|
||||
const request: CastRequest = Array.isArray(requestOrOrigin)
|
||||
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
|
||||
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
|
||||
@@ -4425,6 +4491,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
@@ -5090,7 +5157,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return get().castAbility(abilityId, origin, now);
|
||||
},
|
||||
|
||||
damageMob: (id, amount, now = Date.now(), source, school = "physical", rollContext) => {
|
||||
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
|
||||
const state = get();
|
||||
const threatSource = source ?? playerThreatSource(state);
|
||||
const progression = progressionFromState(state);
|
||||
@@ -5206,7 +5273,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return { mobs };
|
||||
}),
|
||||
|
||||
tauntMob: (id, source, durationMs, now = Date.now()) => {
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
@@ -5257,7 +5324,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return effectiveDurationMs > 0;
|
||||
},
|
||||
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = Date.now()) => {
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
|
||||
let interrupted = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5273,7 +5340,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return interrupted;
|
||||
},
|
||||
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = Date.now()) => {
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
|
||||
let controlled = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5331,7 +5398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
healActor: (id, amount, request) => {
|
||||
const now = request.now ?? Date.now();
|
||||
const now = request.now ?? combatNow();
|
||||
const state = get();
|
||||
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
|
||||
const targetAuras = aurasForEntity(state.auras, id);
|
||||
@@ -5414,7 +5481,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return resolution;
|
||||
},
|
||||
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = Date.now()) => {
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const fraction = clamp(percentMaxHealth, 0.01, 1);
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
@@ -5494,7 +5561,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = Date.now()) => {
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const actor = state.actors[id];
|
||||
if (!actor) return 0;
|
||||
@@ -5533,7 +5600,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = Date.now()) => {
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
|
||||
const state = get();
|
||||
const absorb = Math.max(0, Math.round(amount));
|
||||
if (absorb <= 0 || !state.actors[id]) return 0;
|
||||
@@ -5541,6 +5608,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${sourceActorId}:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: Math.max(1, durationMs),
|
||||
@@ -5570,7 +5638,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return absorb;
|
||||
},
|
||||
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
||||
const durationMs = effect.aura.durationMs;
|
||||
@@ -5600,7 +5668,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) return false;
|
||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||
@@ -5637,7 +5705,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = Date.now()) => {
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
|
||||
const state = get();
|
||||
const owner = state.actors[ownerActorId];
|
||||
if (!owner?.alive) return null;
|
||||
@@ -5671,7 +5739,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return id;
|
||||
},
|
||||
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = Date.now()) => {
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
|
||||
const state = get();
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
if (!sourceActor?.alive) return null;
|
||||
@@ -5716,7 +5784,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
const now = combatNow();
|
||||
const mitigated = damageAfterEquipmentMitigation(
|
||||
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
||||
state.gearStats,
|
||||
@@ -5775,6 +5843,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: "rom-state-revival-charge",
|
||||
name: "Revival",
|
||||
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
maxStacks: 10,
|
||||
@@ -5831,7 +5900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: id,
|
||||
amount: requested,
|
||||
school: "physical",
|
||||
now: Date.now(),
|
||||
now: combatNow(),
|
||||
});
|
||||
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
||||
const health = summon.health - applied;
|
||||
@@ -5846,7 +5915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
summons,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "damage",
|
||||
sourceActorId: "external",
|
||||
targetActorId: id,
|
||||
@@ -5878,12 +5947,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
subjectId: PLAYER_AURA_ENTITY_ID,
|
||||
otherId: PLAYER_AURA_ENTITY_ID,
|
||||
amount: healed,
|
||||
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
set({
|
||||
auras: procResult.auras,
|
||||
health: state.health + healed,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "healing",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5920,7 +5989,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
set((state) => ({
|
||||
activeCast: null,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5936,11 +6005,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
||||
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
||||
}));
|
||||
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
|
||||
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
|
||||
return true;
|
||||
},
|
||||
|
||||
engageNearbyMobs: (now = Date.now(), playerPosition) => {
|
||||
engageNearbyMobs: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -5960,7 +6029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = Date.now(), playerPosition) => {
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -6237,6 +6306,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
||||
name: `${attack.name}: ${effect.mechanic}`,
|
||||
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
|
||||
disposition: "debuff",
|
||||
durationMs: effect.durationMs,
|
||||
dispelCategory: "magic",
|
||||
@@ -6828,7 +6898,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return appliedDamage;
|
||||
},
|
||||
|
||||
tick: (deltaSeconds, now = Date.now()) => {
|
||||
tick: (deltaSeconds, now = combatNow()) => {
|
||||
const partyHotTicks: Array<{
|
||||
memberId: string;
|
||||
amount: number;
|
||||
@@ -7687,7 +7757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
selectedTargetId: null,
|
||||
selectedFriendlyActorId: null,
|
||||
effects: [],
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
||||
summons: [],
|
||||
areaEffects: [],
|
||||
pendingGroundTarget: null,
|
||||
@@ -7746,7 +7816,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
||||
});
|
||||
return true;
|
||||
@@ -7778,7 +7848,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Talent points reset."),
|
||||
};
|
||||
}),
|
||||
@@ -7804,8 +7874,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
updateSettings: (patch) => set((state) => ({ settings: normalizedSettings(state.settings, patch) })),
|
||||
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }),
|
||||
updateSettings: (patch) => set((state) => {
|
||||
const settings = normalizedSettings(state.settings, patch);
|
||||
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
||||
}),
|
||||
resetSettings: () => set((state) => (
|
||||
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
||||
? state
|
||||
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
||||
)),
|
||||
}));
|
||||
|
||||
export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
@@ -7815,14 +7892,14 @@ export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
export function cooldownRemainingMs(
|
||||
state: Pick<CombatState, "cooldowns">,
|
||||
abilityId: string,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
||||
}
|
||||
|
||||
export function globalCooldownRemainingMs(
|
||||
state: Pick<CombatState, "globalCooldownEndsAt">,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, state.globalCooldownEndsAt - now);
|
||||
}
|
||||
@@ -7842,7 +7919,7 @@ useCombatStore.subscribe((state) => {
|
||||
collectingBossLoot = true;
|
||||
try {
|
||||
for (const boss of lootableBosses) {
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now());
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
|
||||
}
|
||||
} finally {
|
||||
collectingBossLoot = false;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
numericFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
describe("frame-rate settings", () => {
|
||||
it("normalizes persisted gameplay and preview limits", () => {
|
||||
expect(normalizeGameplayFrameRateLimit(30)).toBe(30);
|
||||
expect(normalizeGameplayFrameRateLimit("display")).toBe("display");
|
||||
expect(normalizeGameplayFrameRateLimit(144)).toBe(60);
|
||||
expect(normalizePreviewFrameRateLimit(15)).toBe(15);
|
||||
expect(normalizePreviewFrameRateLimit("invalid", 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("cycles through each menu order", () => {
|
||||
expect(nextFrameRateLimit(30, GAMEPLAY_FRAME_RATE_LIMITS)).toBe(60);
|
||||
expect(nextFrameRateLimit("display", GAMEPLAY_FRAME_RATE_LIMITS)).toBe(30);
|
||||
expect(nextFrameRateLimit(30, PREVIEW_FRAME_RATE_LIMITS)).toBe(60);
|
||||
});
|
||||
|
||||
it("formats and resolves capped versus display-refresh modes", () => {
|
||||
expect(frameRateLimitLabel(60)).toBe("60 FPS");
|
||||
expect(frameRateLimitLabel("display")).toBe("Display refresh");
|
||||
expect(numericFrameRateLimit(30)).toBe(30);
|
||||
expect(numericFrameRateLimit("display")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type GameplayFrameRateLimit = 30 | 60 | "display";
|
||||
export type PreviewFrameRateLimit = 15 | 30 | 60 | "display";
|
||||
|
||||
export const GAMEPLAY_FRAME_RATE_LIMITS: readonly GameplayFrameRateLimit[] = [30, 60, "display"];
|
||||
export const PREVIEW_FRAME_RATE_LIMITS: readonly PreviewFrameRateLimit[] = [15, 30, 60, "display"];
|
||||
|
||||
export const DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT: GameplayFrameRateLimit = 60;
|
||||
export const DEFAULT_PREVIEW_FRAME_RATE_LIMIT: PreviewFrameRateLimit = 30;
|
||||
|
||||
export function normalizeGameplayFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: GameplayFrameRateLimit = DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
): GameplayFrameRateLimit {
|
||||
return value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizePreviewFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: PreviewFrameRateLimit = DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
): PreviewFrameRateLimit {
|
||||
return value === 15 || value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function nextFrameRateLimit<T extends number | "display">(
|
||||
current: T,
|
||||
order: readonly T[],
|
||||
): T {
|
||||
const index = order.indexOf(current);
|
||||
return order[(index + 1) % order.length] ?? order[0] ?? current;
|
||||
}
|
||||
|
||||
export function frameRateLimitLabel(limit: number | "display"): string {
|
||||
return limit === "display" ? "Display refresh" : `${limit} FPS`;
|
||||
}
|
||||
|
||||
export function numericFrameRateLimit(limit: number | "display"): number | null {
|
||||
return limit === "display" ? null : limit;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emitControllerToken,
|
||||
orderedControllerTokenTransitions,
|
||||
resetControllerState,
|
||||
startControllerInput,
|
||||
subscribeControllerToken,
|
||||
type ControllerTokenEvent,
|
||||
} from "./controller";
|
||||
@@ -67,4 +68,85 @@ describe("controller button phases", () => {
|
||||
{ token: "Button0", repeat: false, pressed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses low-rate discovery until a gamepad connects and suspends while hidden", () => {
|
||||
vi.useFakeTimers();
|
||||
const windowTarget = new EventTarget() as unknown as Window & typeof globalThis;
|
||||
const documentTarget = new EventTarget() as unknown as Document;
|
||||
let visibilityState: DocumentVisibilityState = "visible";
|
||||
let focused = true;
|
||||
Object.defineProperty(documentTarget, "visibilityState", { get: () => visibilityState });
|
||||
Object.defineProperty(documentTarget, "hasFocus", { value: () => focused });
|
||||
|
||||
let gamepads: Array<Gamepad | null> = [];
|
||||
const getGamepads = vi.fn(() => gamepads as unknown as Gamepad[]);
|
||||
const frameCallbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
frameCallbacks.set(frame, callback);
|
||||
return frame;
|
||||
});
|
||||
const cancelFrame = vi.fn((frame: number) => frameCallbacks.delete(frame));
|
||||
const runFrames = (now: number) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
for (const callback of callbacks) callback(now);
|
||||
};
|
||||
|
||||
vi.stubGlobal("window", windowTarget);
|
||||
vi.stubGlobal("document", documentTarget);
|
||||
vi.stubGlobal("navigator", { getGamepads });
|
||||
vi.stubGlobal("requestAnimationFrame", requestFrame);
|
||||
vi.stubGlobal("cancelAnimationFrame", cancelFrame);
|
||||
|
||||
const stop = startControllerInput();
|
||||
try {
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(0);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(1);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(500);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(2);
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
|
||||
gamepads = [{
|
||||
axes: [0, 0, 0, 0],
|
||||
buttons: [],
|
||||
index: 0,
|
||||
} as unknown as Gamepad];
|
||||
windowTarget.dispatchEvent(new Event("gamepadconnected"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(501);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(3);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
runFrames(517);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
visibilityState = "hidden";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(getGamepads).toHaveBeenCalledTimes(4);
|
||||
|
||||
visibilityState = "visible";
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(frameCallbacks.size).toBe(1);
|
||||
|
||||
focused = false;
|
||||
windowTarget.dispatchEvent(new Event("blur"));
|
||||
expect(frameCallbacks.size).toBe(0);
|
||||
} finally {
|
||||
stop();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+117
-24
@@ -25,6 +25,8 @@ const INITIAL_REPEAT_MS = 280;
|
||||
const REPEAT_MS = 90;
|
||||
const AXIS_THRESHOLD = 0.55;
|
||||
const AXIS_STEPS = 128;
|
||||
const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
|
||||
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
|
||||
|
||||
const listeners = new Set<TokenListener>();
|
||||
const movementListeners = new Set<MovementListener>();
|
||||
@@ -39,26 +41,38 @@ let stopService: (() => void) | null = null;
|
||||
function connectedGamepad(): Gamepad | null {
|
||||
const gamepads = navigator.getGamepads?.();
|
||||
if (!gamepads) return null;
|
||||
return Array.from(gamepads).find((gamepad): gamepad is Gamepad => gamepad !== null) ?? null;
|
||||
for (let index = 0; index < gamepads.length; index += 1) {
|
||||
const gamepad = gamepads[index];
|
||||
if (gamepad) return gamepad;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokensFor(gamepad: Gamepad, tokens: Set<string>): void {
|
||||
tokens.clear();
|
||||
gamepad.buttons.forEach((button, index) => {
|
||||
for (let index = 0; index < gamepad.buttons.length; index += 1) {
|
||||
const button = gamepad.buttons[index];
|
||||
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`);
|
||||
});
|
||||
gamepad.axes.forEach((value, index) => {
|
||||
}
|
||||
for (let index = 0; index < gamepad.axes.length; index += 1) {
|
||||
const value = gamepad.axes[index];
|
||||
if (value <= -AXIS_THRESHOLD) tokens.add(`Axis${index}-`);
|
||||
if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canRepeat(token: string): boolean {
|
||||
return ["Button12", "Button13", "Button14", "Button15"].includes(token)
|
||||
return REPEATABLE_BUTTON_TOKENS.has(token)
|
||||
|| token.startsWith("Axis0")
|
||||
|| token.startsWith("Axis1");
|
||||
}
|
||||
|
||||
function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const token of left) if (!right.has(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLayerModifier(token: string): boolean {
|
||||
return token === "Button4" || token === "Button6";
|
||||
}
|
||||
@@ -114,14 +128,17 @@ function normalizedAxis(value: number): number {
|
||||
}
|
||||
|
||||
export function setExternalControllerMovement(next: ControllerMovement): void {
|
||||
const normalized = {
|
||||
moveX: normalizedAxis(next.moveX),
|
||||
moveY: normalizedAxis(next.moveY),
|
||||
lookX: normalizedAxis(next.lookX),
|
||||
lookY: normalizedAxis(next.lookY),
|
||||
};
|
||||
if (Object.keys(normalized).every((key) => normalized[key as keyof ControllerMovement] === movement[key as keyof ControllerMovement])) return;
|
||||
movement = normalized;
|
||||
const moveX = normalizedAxis(next.moveX);
|
||||
const moveY = normalizedAxis(next.moveY);
|
||||
const lookX = normalizedAxis(next.lookX);
|
||||
const lookY = normalizedAxis(next.lookY);
|
||||
if (
|
||||
moveX === movement.moveX
|
||||
&& moveY === movement.moveY
|
||||
&& lookX === movement.lookX
|
||||
&& lookY === movement.lookY
|
||||
) return;
|
||||
movement = { moveX, moveY, lookX, lookY };
|
||||
for (const listener of movementListeners) listener(movement);
|
||||
}
|
||||
|
||||
@@ -141,6 +158,8 @@ export function resetControllerState(): void {
|
||||
export function startControllerInput(): () => void {
|
||||
if (stopService) return stopService;
|
||||
|
||||
const webGamepadPolling = !Capacitor.isNativePlatform();
|
||||
|
||||
const onNativeToken = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
|
||||
if (!detail?.token) return;
|
||||
@@ -157,25 +176,63 @@ export function startControllerInput(): () => void {
|
||||
if (detail) setExternalControllerMovement(detail);
|
||||
};
|
||||
const onReset = () => resetControllerState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible") resetControllerState();
|
||||
};
|
||||
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.addEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let frame = 0;
|
||||
let discoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hadGamepad = false;
|
||||
let activeGamepadIndex: number | null = null;
|
||||
|
||||
const canPollGamepads = () => document.visibilityState === "visible"
|
||||
&& (typeof document.hasFocus !== "function" || document.hasFocus());
|
||||
|
||||
const cancelScheduledPoll = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
};
|
||||
|
||||
const suspendPolling = () => {
|
||||
cancelScheduledPoll();
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
};
|
||||
|
||||
const scheduleFrame = () => {
|
||||
if (!webGamepadPolling || frame || !canPollGamepads()) return;
|
||||
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
const scheduleDiscovery = () => {
|
||||
if (!webGamepadPolling || frame || discoveryTimer !== null || !canPollGamepads()) return;
|
||||
discoveryTimer = setTimeout(() => {
|
||||
discoveryTimer = null;
|
||||
scheduleFrame();
|
||||
}, GAMEPAD_DISCOVERY_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const poll = (now: number) => {
|
||||
const gamepad = typeof document.hasFocus === "function" && !document.hasFocus()
|
||||
? null
|
||||
: connectedGamepad();
|
||||
frame = 0;
|
||||
if (!canPollGamepads()) {
|
||||
suspendPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const gamepad = connectedGamepad();
|
||||
if (!gamepad) {
|
||||
if (hadGamepad) resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
scheduleDiscovery();
|
||||
} else {
|
||||
hadGamepad = true;
|
||||
activeGamepadIndex = gamepad.index;
|
||||
setExternalControllerMovement({
|
||||
moveX: gamepad.axes[0] ?? 0,
|
||||
moveY: gamepad.axes[1] ?? 0,
|
||||
@@ -183,10 +240,12 @@ export function startControllerInput(): () => void {
|
||||
lookY: gamepad.axes[3] ?? 0,
|
||||
});
|
||||
tokensFor(gamepad, currentTokens);
|
||||
if (!tokenSetsMatch(previousTokens, currentTokens)) {
|
||||
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
||||
emitControllerToken(transition);
|
||||
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
||||
}
|
||||
}
|
||||
for (const token of currentTokens) {
|
||||
if (!previousTokens.has(token) || !canRepeat(token)) continue;
|
||||
const nextRepeat = repeatAt.get(token) ?? 0;
|
||||
@@ -199,17 +258,51 @@ export function startControllerInput(): () => void {
|
||||
const scratch = previousTokens;
|
||||
previousTokens = currentTokens;
|
||||
currentTokens = scratch;
|
||||
scheduleFrame();
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
if (!Capacitor.isNativePlatform()) frame = requestAnimationFrame(poll);
|
||||
|
||||
const onGamepadConnected = () => scheduleFrame();
|
||||
const onGamepadDisconnected = (event: Event) => {
|
||||
const disconnectedIndex = (event as GamepadEvent).gamepad?.index;
|
||||
if (disconnectedIndex === undefined || disconnectedIndex === activeGamepadIndex) {
|
||||
resetControllerState();
|
||||
hadGamepad = false;
|
||||
activeGamepadIndex = null;
|
||||
}
|
||||
scheduleFrame();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") scheduleFrame();
|
||||
else {
|
||||
suspendPolling();
|
||||
if (!webGamepadPolling) resetControllerState();
|
||||
}
|
||||
};
|
||||
const onFocus = () => scheduleFrame();
|
||||
const onBlur = () => suspendPolling();
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
if (webGamepadPolling) {
|
||||
window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
scheduleFrame();
|
||||
}
|
||||
|
||||
stopService = () => {
|
||||
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (webGamepadPolling) {
|
||||
window.removeEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.removeEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
}
|
||||
cancelScheduledPoll();
|
||||
resetControllerState();
|
||||
stopService = null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function documentIsVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether rendering work can produce a visible frame. Page lifecycle
|
||||
* events cover native/WebView transitions where visibilityState can lag.
|
||||
*/
|
||||
export function useDocumentVisibility(): boolean {
|
||||
const [visible, setVisible] = useState(documentIsVisible);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setVisible(documentIsVisible());
|
||||
const hide = () => setVisible(false);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
window.addEventListener("pageshow", refresh);
|
||||
window.addEventListener("pagehide", hide);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
window.removeEventListener("pageshow", refresh);
|
||||
window.removeEventListener("pagehide", hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
|
||||
import {
|
||||
dprForPerformanceFactor,
|
||||
performanceBoundsForFrameRate,
|
||||
} from "./adaptiveGraphicsQualityMath";
|
||||
|
||||
describe("adaptive graphics quality", () => {
|
||||
it("maps the performance range onto the supported pixel ratio", () => {
|
||||
@@ -12,4 +15,11 @@ describe("adaptive graphics quality", () => {
|
||||
expect(dprForPerformanceFactor(-1)).toBe(1);
|
||||
expect(dprForPerformanceFactor(2)).toBe(1.5);
|
||||
});
|
||||
|
||||
it("uses bounds that match an intentional frame-rate cap", () => {
|
||||
expect(performanceBoundsForFrameRate(30, 144)).toEqual([26, 29]);
|
||||
expect(performanceBoundsForFrameRate(60, 144)).toEqual([52, 58]);
|
||||
expect(performanceBoundsForFrameRate("display", 144)).toEqual([60, 100]);
|
||||
expect(performanceBoundsForFrameRate("display", 60)).toEqual([52, 58]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { PerformanceMonitor } from "@react-three/drei";
|
||||
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
import {
|
||||
dprForPerformanceFactor,
|
||||
performanceBoundsForFrameRate,
|
||||
} from "./adaptiveGraphicsQualityMath";
|
||||
|
||||
export function AdaptiveGraphicsQuality({
|
||||
frameRateLimit,
|
||||
onDprChange,
|
||||
}: {
|
||||
readonly frameRateLimit: GameplayFrameRateLimit;
|
||||
readonly onDprChange: (dpr: number) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -13,7 +19,7 @@ export function AdaptiveGraphicsQuality({
|
||||
ms={250}
|
||||
step={0.25}
|
||||
threshold={0.75}
|
||||
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]}
|
||||
bounds={(refreshRate) => performanceBoundsForFrameRate(frameRateLimit, refreshRate)}
|
||||
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
|
||||
|
||||
export function CappedFrameLoop({
|
||||
active = true,
|
||||
targetFps,
|
||||
}: {
|
||||
readonly active?: boolean;
|
||||
readonly targetFps: number;
|
||||
}) {
|
||||
const advance = useThree((state) => state.advance);
|
||||
const clock = useThree((state) => state.clock);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return startCappedFrameLoop({
|
||||
targetFps,
|
||||
getElapsedSeconds: () => clock.elapsedTime,
|
||||
advance,
|
||||
});
|
||||
}, [active, advance, clock, targetFps]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+41
-27
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
@@ -19,34 +18,32 @@ import {
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
playCombatAudio,
|
||||
resetCombatAudio,
|
||||
setCombatAudioListener,
|
||||
stopCombatAudioGroup,
|
||||
} from "../game/combatAudio";
|
||||
|
||||
interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
}
|
||||
import {
|
||||
advanceCombatEffectFrame,
|
||||
capCombatEffects,
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
|
||||
export interface CombatEffectsProps {
|
||||
readonly active: boolean;
|
||||
readonly safeGraphics?: boolean;
|
||||
}
|
||||
|
||||
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
off: 0,
|
||||
low: 24,
|
||||
high: 64,
|
||||
};
|
||||
|
||||
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
|
||||
low: 48,
|
||||
high: 128,
|
||||
};
|
||||
|
||||
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
|
||||
|
||||
function effectDurationMs(event: CombatPresentationEvent): number {
|
||||
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
|
||||
if (event.phase === "tick") return 320;
|
||||
@@ -77,7 +74,7 @@ function deterministicParticles(seed: number, count: number): Float32Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function CombatEffectPrimitive({
|
||||
const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
@@ -119,15 +116,16 @@ function CombatEffectPrimitive({
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current || !groupRef.current) return;
|
||||
elapsedRef.current += Math.min(0.05, delta) * 1_000;
|
||||
const progress = Math.min(1, elapsedRef.current / durationMs);
|
||||
const frame = advanceCombatEffectFrame(elapsedRef.current, delta, durationMs);
|
||||
elapsedRef.current = frame.elapsedMs;
|
||||
const progress = frame.progress;
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
if (projectile) {
|
||||
groupRef.current.position.lerpVectors(origin, target, eased);
|
||||
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
|
||||
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
|
||||
} else {
|
||||
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
|
||||
groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8);
|
||||
const scale = event.phase === "cast-start"
|
||||
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
|
||||
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
|
||||
@@ -140,7 +138,7 @@ function CombatEffectPrimitive({
|
||||
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
|
||||
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
|
||||
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
|
||||
if (progress >= 1) {
|
||||
if (frame.done) {
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
}
|
||||
@@ -234,12 +232,13 @@ function CombatEffectPrimitive({
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
{quality === "high" && event.school !== "physical" ? (
|
||||
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
}, (previous, next) => (
|
||||
previous.event === next.event
|
||||
&& previous.quality === next.quality
|
||||
&& previous.active === next.active
|
||||
));
|
||||
|
||||
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
|
||||
const settings = useShellStore((state) => state.combatPresentationSettings);
|
||||
@@ -253,6 +252,21 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
const qualityRef = useRef(effectiveQuality);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const forward = useMemo(() => new Vector3(), []);
|
||||
const audioOutputEnabled = combatAudioOutputEnabled(settings, masterVolume);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
|
||||
quality: effectiveQuality,
|
||||
systems: effects.length,
|
||||
activeLights: 0,
|
||||
allocatedLights: 0,
|
||||
});
|
||||
}, [effectiveQuality, effects.length]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
@@ -260,11 +274,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
configureCombatAudio(settings, masterVolume);
|
||||
if (
|
||||
qualityRef.current !== "off"
|
||||
|| Object.values(settings.audioEnabled).some(Boolean)
|
||||
|| combatAudioOutputEnabled(settings, masterVolume)
|
||||
) void preloadCombatEffectManifest();
|
||||
setEffects((current) => current.filter(({ event }) => (
|
||||
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
|
||||
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
|
||||
)));
|
||||
)), qualityRef.current));
|
||||
for (const group of ["player", "party", "enemy"] as const) {
|
||||
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
|
||||
}
|
||||
@@ -298,14 +312,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
|| quality === "off"
|
||||
|| !presentationSettings.vfxEnabled[event.actorGroup]
|
||||
) return withoutCast;
|
||||
const limit = QUALITY_SYSTEM_LIMIT[quality];
|
||||
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
|
||||
return updateCombatEffectQueue(withoutCast, event, quality);
|
||||
});
|
||||
}), []);
|
||||
|
||||
useEffect(() => () => resetCombatAudio(), []);
|
||||
|
||||
useFrame(() => {
|
||||
if (!active || !audioOutputEnabled) return;
|
||||
camera.getWorldDirection(forward);
|
||||
setCombatAudioListener(
|
||||
[camera.position.x, camera.position.y, camera.position.z],
|
||||
|
||||
+24
-1
@@ -47,6 +47,10 @@ import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
|
||||
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
|
||||
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { numericFrameRateLimit } from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "./CappedFrameLoop";
|
||||
|
||||
export function GameScene() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -95,8 +99,10 @@ export function GameScene() {
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const mapOpen = useGameStore((state) => state.mapOpen);
|
||||
const companionOpen = useGameStore((state) => state.companionOpen);
|
||||
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
|
||||
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
|
||||
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
|
||||
const [safeGraphics, setSafeGraphics] = useState(false);
|
||||
@@ -106,6 +112,7 @@ export function GameScene() {
|
||||
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
|
||||
const simulationBlocked = graphicsRecovery !== null
|
||||
|| !worldReady
|
||||
|| (!documentVisible && !sharedOnline)
|
||||
|| (!sharedOnline && (paused || mapOpen || companionOpen));
|
||||
const frameloop = sceneFrameLoop({
|
||||
worldReady,
|
||||
@@ -114,7 +121,10 @@ export function GameScene() {
|
||||
paused,
|
||||
mapOpen,
|
||||
companionOpen,
|
||||
documentVisible,
|
||||
frameRateLimit: gameplayFrameRate,
|
||||
});
|
||||
const targetFps = numericFrameRateLimit(gameplayFrameRate);
|
||||
const allowedRuntimeIds = useMemo(
|
||||
() => gameMode === "manastorm" && manastormEncounter
|
||||
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
|
||||
@@ -204,6 +214,8 @@ export function GameScene() {
|
||||
onLostPointerCapture={endMouseLook}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
aria-label="Healer Man 3D dungeon view"
|
||||
data-frame-rate-limit={gameplayFrameRate}
|
||||
data-frame-loop={frameloop}
|
||||
>
|
||||
<Canvas
|
||||
key={rendererCanvasKey(canvasRevision)}
|
||||
@@ -226,8 +238,19 @@ export function GameScene() {
|
||||
powerPreference: safeGraphics ? "default" : "high-performance",
|
||||
}}
|
||||
>
|
||||
{frameloop === "never" && targetFps !== null && (
|
||||
<CappedFrameLoop
|
||||
active={documentVisible || sharedOnline}
|
||||
targetFps={targetFps}
|
||||
/>
|
||||
)}
|
||||
<GameGltfLoaderLifecycle>
|
||||
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />}
|
||||
{!safeGraphics && worldReady && (
|
||||
<AdaptiveGraphicsQuality
|
||||
frameRateLimit={gameplayFrameRate}
|
||||
onDprChange={setAdaptiveDpr}
|
||||
/>
|
||||
)}
|
||||
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
|
||||
<color attach="background" args={[dungeon.presentation.background]} />
|
||||
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
export function dprForPerformanceFactor(factor: number): number {
|
||||
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
|
||||
}
|
||||
|
||||
export function performanceBoundsForFrameRate(
|
||||
frameRateLimit: GameplayFrameRateLimit,
|
||||
refreshRate: number,
|
||||
): [number, number] {
|
||||
if (frameRateLimit === 30) return [26, 29];
|
||||
if (frameRateLimit === 60) return [52, 58];
|
||||
return refreshRate > 100 ? [60, 100] : [52, 58];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
|
||||
|
||||
function createFrameQueue() {
|
||||
const callbacks = new Map<number, (timestampMs: number) => void>();
|
||||
const cancelled: number[] = [];
|
||||
let nextFrame = 1;
|
||||
|
||||
return {
|
||||
callbacks,
|
||||
cancelled,
|
||||
requestFrame(callback: (timestampMs: number) => void) {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
callbacks.set(frame, callback);
|
||||
return frame;
|
||||
},
|
||||
cancelFrame(frame: number) {
|
||||
cancelled.push(frame);
|
||||
callbacks.delete(frame);
|
||||
},
|
||||
run(frame: number, timestampMs: number) {
|
||||
const callback = callbacks.get(frame);
|
||||
callbacks.delete(frame);
|
||||
callback?.(timestampMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("capped frame-loop runtime", () => {
|
||||
it("starts from Fiber's committed elapsed-seconds clock", () => {
|
||||
const queue = createFrameQueue();
|
||||
const advances: number[] = [];
|
||||
const stop = startCappedFrameLoop({
|
||||
targetFps: 30,
|
||||
getElapsedSeconds: () => 42,
|
||||
advance: (elapsedSeconds) => advances.push(elapsedSeconds),
|
||||
requestFrame: queue.requestFrame,
|
||||
cancelFrame: queue.cancelFrame,
|
||||
});
|
||||
|
||||
queue.run(1, 1_000);
|
||||
queue.run(2, 1_034);
|
||||
|
||||
expect(advances).toEqual([42, 42.034]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("does not schedule a zombie frame after re-entrant cleanup", () => {
|
||||
const queue = createFrameQueue();
|
||||
let stop: () => void = () => undefined;
|
||||
stop = startCappedFrameLoop({
|
||||
targetFps: 60,
|
||||
getElapsedSeconds: () => 0,
|
||||
advance: () => stop(),
|
||||
requestFrame: queue.requestFrame,
|
||||
cancelFrame: queue.cancelFrame,
|
||||
});
|
||||
|
||||
queue.run(1, 500);
|
||||
|
||||
expect(queue.callbacks.size).toBe(0);
|
||||
expect(queue.cancelled).toEqual([1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
createFrameRateGovernor,
|
||||
stepFrameRateGovernor,
|
||||
} from "./frameRateGovernor";
|
||||
|
||||
interface CappedFrameLoopRuntimeOptions {
|
||||
readonly targetFps: number;
|
||||
readonly getElapsedSeconds: () => number;
|
||||
readonly advance: (elapsedSeconds: number) => void;
|
||||
readonly requestFrame?: (callback: (timestampMs: number) => void) => number;
|
||||
readonly cancelFrame?: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function startCappedFrameLoop({
|
||||
targetFps,
|
||||
getElapsedSeconds,
|
||||
advance,
|
||||
requestFrame = requestAnimationFrame,
|
||||
cancelFrame = cancelAnimationFrame,
|
||||
}: CappedFrameLoopRuntimeOptions): () => void {
|
||||
let frame = 0;
|
||||
let cancelled = false;
|
||||
let governor = createFrameRateGovernor(targetFps);
|
||||
let elapsedBase: number | null = null;
|
||||
|
||||
const renderAtTargetRate = (timestampMs: number) => {
|
||||
if (cancelled) return;
|
||||
if (elapsedBase === null) {
|
||||
// Read after the Canvas has committed its frameloop change; Fiber resets
|
||||
// the clock when entering "never" mode.
|
||||
const elapsedSeconds = getElapsedSeconds();
|
||||
elapsedBase = Number.isFinite(elapsedSeconds)
|
||||
? Math.max(0, elapsedSeconds)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const step = stepFrameRateGovernor(governor, timestampMs);
|
||||
governor = step.state;
|
||||
if (step.shouldRender) advance(elapsedBase + step.elapsedSeconds);
|
||||
if (!cancelled) frame = requestFrame(renderAtTargetRate);
|
||||
};
|
||||
|
||||
frame = requestFrame(renderAtTargetRate);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelFrame(frame);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CombatPresentationEvent } from "../game/combatPresentation";
|
||||
import {
|
||||
COMBAT_EFFECT_SYSTEM_LIMIT,
|
||||
advanceCombatEffectFrame,
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
|
||||
function event(
|
||||
id: number,
|
||||
patch: Partial<CombatPresentationEvent> = {},
|
||||
): CombatPresentationEvent {
|
||||
return {
|
||||
id,
|
||||
sessionId: 1,
|
||||
occurredAt: id,
|
||||
phase: "impact",
|
||||
actorGroup: "enemy",
|
||||
sourceActorId: `enemy-${id}`,
|
||||
targetActorId: "player",
|
||||
abilityId: "fixture",
|
||||
name: "Fixture",
|
||||
source: "fallback",
|
||||
school: "shadow",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(
|
||||
events: readonly CombatPresentationEvent[],
|
||||
quality: "off" | "low" | "high",
|
||||
): readonly ActiveCombatEffect[] {
|
||||
return events.reduce<readonly ActiveCombatEffect[]>(
|
||||
(current, next) => updateCombatEffectQueue(current, next, quality),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("combat-effect runtime budget", () => {
|
||||
it("expires effects by wall time while bounding motion after a hitch", () => {
|
||||
let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
|
||||
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
|
||||
frame = advanceCombatEffectFrame(frame.elapsedMs, 0.5, 420);
|
||||
|
||||
expect(frame.elapsedMs).toBeCloseTo(533.333, 2);
|
||||
expect(frame.motionDeltaSeconds).toBe(0.05);
|
||||
expect(frame.progress).toBe(1);
|
||||
expect(frame.done).toBe(true);
|
||||
expect(advanceCombatEffectFrame(frame.elapsedMs, Number.NaN, 420).elapsedMs)
|
||||
.toBe(frame.elapsedMs);
|
||||
});
|
||||
|
||||
it("bounds large bursts while retaining player feedback and the newest event", () => {
|
||||
const player = event(1, {
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
school: "holy",
|
||||
});
|
||||
const enemies = Array.from({ length: 512 }, (_, index) => event(index + 2, {
|
||||
phase: "tick",
|
||||
}));
|
||||
|
||||
const high = enqueue([player, ...enemies], "high");
|
||||
const low = enqueue([player, ...enemies], "low");
|
||||
|
||||
expect(high).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.high);
|
||||
expect(low).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
||||
expect(enqueue([player], "off")).toEqual([]);
|
||||
expect(updateCombatEffectQueue(enqueue([player], "high"), enemies[0], "off"))
|
||||
.toEqual([]);
|
||||
expect(high.some(({ event: active }) => active.id === player.id)).toBe(true);
|
||||
expect(high.at(-1)?.event.id).toBe(enemies.at(-1)?.id);
|
||||
expect(new Set(high.map(({ key }) => key)).size).toBe(high.length);
|
||||
});
|
||||
|
||||
it("keeps combat-significant enemy casts ahead of low-value friendly ticks", () => {
|
||||
const partyTicks = Array.from({ length: COMBAT_EFFECT_SYSTEM_LIMIT.low }, (_, index) => event(index + 1, {
|
||||
actorGroup: "party",
|
||||
sourceActorId: `party-${index}`,
|
||||
phase: "tick",
|
||||
}));
|
||||
const enemyCast = event(100, {
|
||||
actorGroup: "enemy",
|
||||
sourceActorId: "boss",
|
||||
phase: "cast-start",
|
||||
});
|
||||
|
||||
const effects = enqueue([...partyTicks, enemyCast], "low");
|
||||
expect(effects).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
||||
expect(effects.some(({ event: active }) => active.id === enemyCast.id)).toBe(true);
|
||||
expect(effects.filter(({ event: active }) => active.phase === "tick"))
|
||||
.toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low - 1);
|
||||
});
|
||||
|
||||
it("coalesces a single area burst but preserves projectile phases", () => {
|
||||
const areaRelease = event(1, {
|
||||
occurredAt: 100,
|
||||
phase: "release",
|
||||
delivery: "area",
|
||||
sourceActorId: "caster",
|
||||
});
|
||||
const areaImpacts = Array.from({ length: 5 }, (_, index) => event(index + 2, {
|
||||
occurredAt: 100,
|
||||
phase: "impact",
|
||||
delivery: "area",
|
||||
sourceActorId: "caster",
|
||||
targetActorId: `target-${index}`,
|
||||
}));
|
||||
const area = enqueue([areaRelease, ...areaImpacts], "high");
|
||||
expect(area).toHaveLength(1);
|
||||
expect(area[0].event.phase).toBe("impact");
|
||||
|
||||
const projectile = enqueue([
|
||||
event(10, { occurredAt: 200, phase: "release", sourceActorId: "caster" }),
|
||||
event(11, { occurredAt: 200, phase: "impact", sourceActorId: "caster" }),
|
||||
], "high");
|
||||
expect(projectile).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
CombatPresentationEvent,
|
||||
CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
|
||||
export interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
}
|
||||
|
||||
export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
off: 0,
|
||||
low: 24,
|
||||
high: 32,
|
||||
};
|
||||
|
||||
export interface CombatEffectFrameState {
|
||||
readonly elapsedMs: number;
|
||||
readonly motionDeltaSeconds: number;
|
||||
readonly progress: number;
|
||||
readonly done: boolean;
|
||||
}
|
||||
|
||||
export function advanceCombatEffectFrame(
|
||||
elapsedMs: number,
|
||||
rawDeltaSeconds: number,
|
||||
durationMs: number,
|
||||
): CombatEffectFrameState {
|
||||
const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0;
|
||||
const deltaSeconds = Number.isFinite(rawDeltaSeconds) ? Math.max(0, rawDeltaSeconds) : 0;
|
||||
const safeDurationMs = Number.isFinite(durationMs) ? Math.max(1, durationMs) : 1;
|
||||
// Age follows wall time so a slow frame expires work instead of extending the
|
||||
// overload. Motion remains bounded to avoid a single-frame visual jump.
|
||||
const nextElapsedMs = safeElapsedMs + deltaSeconds * 1_000;
|
||||
const progress = Math.min(1, nextElapsedMs / safeDurationMs);
|
||||
return {
|
||||
elapsedMs: nextElapsedMs,
|
||||
motionDeltaSeconds: Math.min(0.05, deltaSeconds),
|
||||
progress,
|
||||
done: progress >= 1,
|
||||
};
|
||||
}
|
||||
|
||||
function actorPriority(event: CombatPresentationEvent): number {
|
||||
const groupPriority = event.actorGroup === "player" ? 30 : event.actorGroup === "party" ? 20 : 10;
|
||||
// Preserve readable combat beats before actor ownership. An enemy cast or
|
||||
// impact is more useful than another periodic tick from any actor group.
|
||||
const phasePriority = event.phase === "cast-start"
|
||||
? 300
|
||||
: event.phase === "release" || event.phase === "impact"
|
||||
? 200
|
||||
: event.phase === "tick"
|
||||
? 0
|
||||
: 100;
|
||||
return groupPriority + phasePriority;
|
||||
}
|
||||
|
||||
function areaBurstKey(event: CombatPresentationEvent): string | null {
|
||||
if (event.delivery !== "area" || event.phase === "cast-start" || event.phase === "cast-cancel") {
|
||||
return null;
|
||||
}
|
||||
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}:${event.occurredAt}`;
|
||||
}
|
||||
|
||||
function effectKey(event: CombatPresentationEvent): string {
|
||||
return `${event.sessionId}:${event.id}`;
|
||||
}
|
||||
|
||||
function trimCombatEffects(
|
||||
effects: readonly ActiveCombatEffect[],
|
||||
limit: number,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
if (effects.length <= limit) return effects;
|
||||
const next = [...effects];
|
||||
while (next.length > limit) {
|
||||
let removalIndex = 0;
|
||||
let removalPriority = actorPriority(next[0].event);
|
||||
for (let index = 1; index < next.length; index += 1) {
|
||||
const priority = actorPriority(next[index].event);
|
||||
if (priority < removalPriority) {
|
||||
removalIndex = index;
|
||||
removalPriority = priority;
|
||||
}
|
||||
}
|
||||
next.splice(removalIndex, 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function capCombatEffects(
|
||||
effects: readonly ActiveCombatEffect[],
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
const limit = COMBAT_EFFECT_SYSTEM_LIMIT[quality];
|
||||
if (limit === 0) return effects.length === 0 ? effects : [];
|
||||
return trimCombatEffects(effects, limit);
|
||||
}
|
||||
|
||||
export function updateCombatEffectQueue(
|
||||
current: readonly ActiveCombatEffect[],
|
||||
event: CombatPresentationEvent,
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
|
||||
? current.filter((effect) => !(
|
||||
effect.event.phase === "cast-start"
|
||||
&& effect.event.sourceActorId === event.sourceActorId
|
||||
&& effect.event.abilityId === event.abilityId
|
||||
))
|
||||
: current;
|
||||
if (quality === "off") return current.length === 0 ? current : [];
|
||||
if (event.phase === "cast-cancel") {
|
||||
return withoutCast.length === current.length ? current : withoutCast;
|
||||
}
|
||||
|
||||
const burstKey = areaBurstKey(event);
|
||||
if (burstKey) {
|
||||
const existingIndex = withoutCast.findIndex((effect) => areaBurstKey(effect.event) === burstKey);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = withoutCast[existingIndex];
|
||||
if (existing.event.phase === event.phase) return withoutCast;
|
||||
const next = [...withoutCast];
|
||||
next[existingIndex] = { key: effectKey(event), event };
|
||||
return capCombatEffects(next, quality);
|
||||
}
|
||||
}
|
||||
|
||||
const next = capCombatEffects(
|
||||
[...withoutCast, { key: effectKey(event), event }],
|
||||
quality,
|
||||
);
|
||||
if (
|
||||
next.length === current.length
|
||||
&& next.every((effect, index) => effect.key === current[index]?.key)
|
||||
) return current;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFrameRateGovernor,
|
||||
resetFrameRateGovernor,
|
||||
stepFrameRateGovernor,
|
||||
} from "./frameRateGovernor";
|
||||
|
||||
function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000): number {
|
||||
let state = createFrameRateGovernor(targetFps);
|
||||
let renders = 0;
|
||||
for (let timestamp = 0; timestamp <= durationMs; timestamp += 1_000 / sourceFps) {
|
||||
const step = stepFrameRateGovernor(state, timestamp);
|
||||
state = step.state;
|
||||
if (step.shouldRender) renders += 1;
|
||||
}
|
||||
return renders;
|
||||
}
|
||||
|
||||
describe("frame-rate governor", () => {
|
||||
it("holds capped modes near their target on high-refresh displays", () => {
|
||||
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
|
||||
expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
|
||||
expect(renderCount(60, 240)).toBeGreaterThanOrEqual(60);
|
||||
expect(renderCount(60, 240)).toBeLessThanOrEqual(61);
|
||||
});
|
||||
|
||||
it("renders every source callback in display mode", () => {
|
||||
let state = createFrameRateGovernor("display", 4);
|
||||
for (const timestamp of [1_000, 1_008, 1_016]) {
|
||||
const step = stepFrameRateGovernor(state, timestamp);
|
||||
state = step.state;
|
||||
expect(step.shouldRender).toBe(true);
|
||||
}
|
||||
expect(state.elapsedSeconds).toBeCloseTo(4.016);
|
||||
});
|
||||
|
||||
it("skips missed deadlines without emitting a catch-up burst", () => {
|
||||
let state = createFrameRateGovernor(60);
|
||||
state = stepFrameRateGovernor(state, 0).state;
|
||||
const delayed = stepFrameRateGovernor(state, 100);
|
||||
expect(delayed.shouldRender).toBe(true);
|
||||
expect(delayed.state.nextDeadlineMs).toBeGreaterThan(100);
|
||||
const sameTimestamp = stepFrameRateGovernor(delayed.state, 100);
|
||||
expect(sameTimestamp.shouldRender).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves elapsed time while resetting the wall-clock baseline", () => {
|
||||
let state = createFrameRateGovernor(30, 2);
|
||||
state = stepFrameRateGovernor(state, 1_000).state;
|
||||
state = stepFrameRateGovernor(state, 1_034).state;
|
||||
const reset = resetFrameRateGovernor(state, 60);
|
||||
const resumed = stepFrameRateGovernor(reset, 90_000);
|
||||
expect(resumed.shouldRender).toBe(true);
|
||||
expect(resumed.elapsedSeconds).toBeCloseTo(state.elapsedSeconds);
|
||||
});
|
||||
|
||||
it("never moves elapsed time backwards", () => {
|
||||
let state = createFrameRateGovernor(60);
|
||||
state = stepFrameRateGovernor(state, 20).state;
|
||||
state = stepFrameRateGovernor(state, 40).state;
|
||||
const backwards = stepFrameRateGovernor(state, 10);
|
||||
expect(backwards.elapsedSeconds).toBe(state.elapsedSeconds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
export type FrameRateGovernorMode = number | "display";
|
||||
|
||||
export interface FrameRateGovernorState {
|
||||
readonly mode: FrameRateGovernorMode;
|
||||
readonly lastTimestampMs: number | null;
|
||||
readonly nextDeadlineMs: number | null;
|
||||
readonly elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface FrameRateGovernorStep {
|
||||
readonly state: FrameRateGovernorState;
|
||||
readonly shouldRender: boolean;
|
||||
readonly elapsedSeconds: number;
|
||||
}
|
||||
|
||||
function frameIntervalMs(mode: FrameRateGovernorMode): number | null {
|
||||
if (mode === "display") return null;
|
||||
return Number.isFinite(mode) && mode > 0 ? 1_000 / mode : 1_000 / 60;
|
||||
}
|
||||
|
||||
export function createFrameRateGovernor(
|
||||
mode: FrameRateGovernorMode,
|
||||
elapsedSeconds = 0,
|
||||
): FrameRateGovernorState {
|
||||
return {
|
||||
mode,
|
||||
lastTimestampMs: null,
|
||||
nextDeadlineMs: null,
|
||||
elapsedSeconds: Math.max(0, elapsedSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetFrameRateGovernor(
|
||||
state: FrameRateGovernorState,
|
||||
mode: FrameRateGovernorMode = state.mode,
|
||||
): FrameRateGovernorState {
|
||||
return createFrameRateGovernor(mode, state.elapsedSeconds);
|
||||
}
|
||||
|
||||
export function stepFrameRateGovernor(
|
||||
state: FrameRateGovernorState,
|
||||
rawTimestampMs: number,
|
||||
): FrameRateGovernorStep {
|
||||
const fallbackTimestamp = state.lastTimestampMs ?? 0;
|
||||
const finiteTimestamp = Number.isFinite(rawTimestampMs) ? rawTimestampMs : fallbackTimestamp;
|
||||
const timestampMs = Math.max(fallbackTimestamp, finiteTimestamp);
|
||||
const intervalMs = frameIntervalMs(state.mode);
|
||||
|
||||
if (state.lastTimestampMs === null) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
nextDeadlineMs: intervalMs === null ? null : timestampMs + intervalMs,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds: nextState.elapsedSeconds };
|
||||
}
|
||||
|
||||
const elapsedSeconds = state.elapsedSeconds + (timestampMs - state.lastTimestampMs) / 1_000;
|
||||
if (intervalMs === null) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds };
|
||||
}
|
||||
|
||||
const deadlineMs = state.nextDeadlineMs ?? timestampMs;
|
||||
if (timestampMs < deadlineMs) {
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: false, elapsedSeconds };
|
||||
}
|
||||
|
||||
const missedIntervals = Math.floor(Math.max(0, timestampMs - deadlineMs) / intervalMs);
|
||||
const nextState: FrameRateGovernorState = {
|
||||
...state,
|
||||
lastTimestampMs: timestampMs,
|
||||
nextDeadlineMs: deadlineMs + (missedIntervals + 1) * intervalMs,
|
||||
elapsedSeconds,
|
||||
};
|
||||
return { state: nextState, shouldRender: true, elapsedSeconds };
|
||||
}
|
||||
@@ -28,13 +28,18 @@ describe("R3F scene lifecycle", () => {
|
||||
paused: false,
|
||||
mapOpen: false,
|
||||
companionOpen: false,
|
||||
documentVisible: true,
|
||||
frameRateLimit: 60 as const,
|
||||
};
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always");
|
||||
expect(sceneFrameLoop(readyWorld)).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("never");
|
||||
expect(sceneFrameLoop(readyWorld)).toBe("never");
|
||||
expect(sceneFrameLoop({ ...readyWorld, frameRateLimit: "display" })).toBe("always");
|
||||
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false })).toBe("demand");
|
||||
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false, sharedOnline: true })).toBe("never");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import type { Frameloop } from "@react-three/fiber";
|
||||
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
/**
|
||||
* Encounter transitions must never remount the Canvas: R3F intentionally loses
|
||||
@@ -41,10 +42,14 @@ export function sceneFrameLoop(options: {
|
||||
readonly paused: boolean;
|
||||
readonly mapOpen: boolean;
|
||||
readonly companionOpen: boolean;
|
||||
readonly documentVisible: boolean;
|
||||
readonly frameRateLimit: GameplayFrameRateLimit;
|
||||
}): Frameloop {
|
||||
if (!options.documentVisible && !options.sharedOnline) return "demand";
|
||||
const locallyPaused = options.worldReady
|
||||
&& !options.graphicsRecovering
|
||||
&& !options.sharedOnline
|
||||
&& (options.paused || options.mapOpen || options.companionOpen);
|
||||
return locallyPaused ? "demand" : "always";
|
||||
if (locallyPaused) return "demand";
|
||||
return options.frameRateLimit === "display" ? "always" : "never";
|
||||
}
|
||||
+45
-6
@@ -758,6 +758,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
text-align: right;
|
||||
text-shadow: 0 1px 2px #000, 0 0 3px #000;
|
||||
}
|
||||
.timed-effect--inspectable { cursor: help; transition: border-color 100ms ease, filter 100ms ease, transform 100ms ease; }
|
||||
.timed-effect--inspectable:hover, .timed-effect--inspectable:focus-visible { z-index: 2; border-color: #f4dd8c; outline: 1px solid rgba(244,221,140,.58); filter: brightness(1.16); transform: translateY(-1px); }
|
||||
.timed-effect-strip--party { min-height: 16px; margin-top: 1px; }
|
||||
.timed-effect-strip--party .timed-effect { width: 16px; height: 16px; }
|
||||
.timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; }
|
||||
@@ -774,6 +776,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
|
||||
.aura--inspectable { cursor: help; transition: border-color 100ms ease, filter 100ms ease, transform 100ms ease; }
|
||||
.aura--inspectable:hover, .aura--inspectable:focus-visible { z-index: 2; border-color: #f4dd8c; outline: 1px solid rgba(244,221,140,.58); filter: brightness(1.16); transform: translateY(-1px); }
|
||||
.aura-strip--target { justify-content: center; margin-top: 3px; }
|
||||
.aura-strip--party { min-height: 16px; margin-top: 1px; }
|
||||
.aura-strip--party .aura { width: 16px; height: 16px; }
|
||||
@@ -788,11 +792,23 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-command-bar strong { min-width: 0; overflow: hidden; font-size: 7px; letter-spacing: .03em; text-overflow: ellipsis; text-transform: uppercase; }
|
||||
.party-command-bar kbd { flex: 0 0 auto; padding: 1px 3px; border: 1px solid rgba(230,213,147,.2); border-radius: 3px; background: rgba(0,0,0,.35); color: #dfcf91; font-size: 6px; }
|
||||
|
||||
.hud-minimap {
|
||||
.hud-top-right {
|
||||
position: absolute;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
right: max(12px, env(safe-area-inset-right));
|
||||
display: grid;
|
||||
width: 154px;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: stretch;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
scale: var(--game-ui-scale, 1);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.hud-minimap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -818,6 +834,28 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.hud-minimap__caption strong { font: 600 11px Georgia, serif; }
|
||||
.hud-minimap__caption span { margin-top: 2px; color: var(--muted); font-size: 7px; }
|
||||
|
||||
.player-status-panel { position: relative; display: grid; width: 100%; min-width: 0; gap: 4px; pointer-events: auto; }
|
||||
.player-status-panel__section { padding: 4px; border: 1px solid rgba(164,202,137,.24); border-radius: 5px 2px 7px 2px; background: linear-gradient(105deg, rgba(5,15,11,.92), rgba(12,29,20,.84)); box-shadow: 0 5px 16px rgba(0,0,0,.3); backdrop-filter: blur(5px); }
|
||||
.player-status-panel__section--debuffs { border-color: rgba(219,112,94,.28); background: linear-gradient(105deg, rgba(29,10,10,.91), rgba(22,15,13,.86)); }
|
||||
.player-status-panel__section > header { display: flex; align-items: center; justify-content: space-between; gap: 5px; margin-bottom: 3px; color: #c7d8b8; font-size: 6px; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.player-status-panel__section--debuffs > header { color: #e7b09e; }
|
||||
.player-status-panel__section > header b { display: grid; min-width: 13px; height: 13px; place-items: center; border-radius: 7px; background: rgba(135,176,107,.16); color: #e5efdb; font-size: 6px; }
|
||||
.player-status-panel__section--debuffs > header b { background: rgba(205,94,77,.16); color: #ffd2c2; }
|
||||
.player-status-panel__icons { display: flex; min-height: 22px; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 3px; }
|
||||
.player-status-panel__strip { display: contents; }
|
||||
.player-status-panel__empty { align-self: center; color: rgba(177,192,166,.62); font-size: 6px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.player-status-panel__section--debuffs .player-status-panel__empty { color: rgba(211,166,151,.6); }
|
||||
.player-status-panel__synthetic { flex: 0 0 auto; }
|
||||
.player-status-panel__synthetic .aura__timer:empty { display: none; }
|
||||
.player-status-tooltip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: min(250px, calc(100vw - 24px)); padding: 8px 9px; border: 1px solid rgba(190,218,157,.42); border-radius: 7px 2px 9px 2px; background: linear-gradient(145deg, rgba(8,21,14,.98), rgba(3,10,7,.98)); box-shadow: 0 12px 30px rgba(0,0,0,.58), inset 0 0 18px rgba(122,176,91,.06); color: #e9efe2; text-align: left; pointer-events: none; }
|
||||
.player-status-tooltip--debuff { border-color: rgba(228,122,101,.5); background: linear-gradient(145deg, rgba(35,12,11,.98), rgba(12,7,7,.98)); }
|
||||
.player-status-tooltip > header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.player-status-tooltip > header strong { font: 600 11px Georgia, serif; }
|
||||
.player-status-tooltip > header span { color: #9ece87; font-size: 7px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.player-status-tooltip--debuff > header span { color: #ef9b86; }
|
||||
.player-status-tooltip p { margin: 5px 0 7px; color: #c3cebb; font-size: 8px; line-height: 1.42; }
|
||||
.player-status-tooltip footer { display: flex; flex-wrap: wrap; gap: 3px 7px; padding-top: 5px; border-top: 1px solid rgba(200,220,177,.12); color: #dfcb83; font-size: 7px; text-transform: capitalize; }
|
||||
|
||||
.hud-bottom-stack {
|
||||
position: absolute;
|
||||
bottom: max(22px, calc(env(safe-area-inset-bottom) + 16px));
|
||||
@@ -919,9 +957,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.cast-bar__track { height: 9px; margin-top: 5px; overflow: hidden; border: 1px solid rgba(255,255,255,.12); border-radius: 5px; background: rgba(0,0,0,.7); }
|
||||
.cast-bar__track span { display: block; height: 100%; background: linear-gradient(90deg, #c39a47, #f2dc80); box-shadow: 0 0 9px rgba(240,210,111,.42); transition: width 80ms linear; }
|
||||
.cast-bar--channel .cast-bar__track span { background: linear-gradient(90deg, #7551a9, #bc8be2); }
|
||||
.player-frame, .hud-minimap, .target-frame { scale: var(--game-ui-scale, 1); }
|
||||
.player-frame, .target-frame { scale: var(--game-ui-scale, 1); }
|
||||
.player-frame { transform-origin: top left; }
|
||||
.hud-minimap { transform-origin: top right; }
|
||||
.target-frame { transform-origin: top center; }
|
||||
.action-slot__icon img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
|
||||
|
||||
@@ -1302,7 +1339,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.player-frame__portrait { height: 48px; }
|
||||
.party-frames { top: max(91px, calc(env(safe-area-inset-top) + 79px)); width: 194px; }
|
||||
.party-frame--player { display: none; }
|
||||
.hud-minimap { width: 124px; }
|
||||
.hud-top-right { width: 124px; }
|
||||
.hud-minimap__map { width: 108px; height: 108px; }
|
||||
.spellbook-panel__layout { grid-template-columns: 1fr; }
|
||||
.spellbook-panel__details { display: none; }
|
||||
@@ -1337,9 +1374,11 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.party-frame { min-height: 34px; grid-template-columns: 25px minmax(0,1fr); padding: 3px 4px; }
|
||||
.party-frame__sigil { width: 23px; height: 23px; }
|
||||
.party-frame__identity small { display: none; }
|
||||
.hud-minimap { right: 7px; width: 94px; }
|
||||
.hud-top-right { right: 7px; width: 94px; }
|
||||
.hud-minimap__map { width: 86px; height: 86px; }
|
||||
.hud-minimap__caption { display: none; }
|
||||
.player-status-panel__section { padding: 3px; }
|
||||
.player-status-tooltip { width: min(220px, 68vw); }
|
||||
.hud-bottom-stack > .action-bar { width: min(360px, calc(100vw - 18px)); }
|
||||
.hud-bottom-stack > .control-hint { max-width: min(360px, calc(100vw - 18px)); }
|
||||
.action-bar { --action-slot-size: 36px; width: calc(100vw - 12px); }
|
||||
@@ -1379,7 +1418,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.player-frame { top: 7px; left: 7px; transform: scale(.88); transform-origin: top left; }
|
||||
.hud-minimap { top: 6px; right: 7px; transform: scale(.86); transform-origin: top right; }
|
||||
.hud-top-right { top: 6px; right: 7px; transform: scale(.86); transform-origin: top right; }
|
||||
.hud-bottom-stack > .action-bar { width: min(360px, calc(100vw - 18px)); }
|
||||
.hud-bottom-stack > .control-hint { max-width: min(440px, calc(100vw - 18px)); }
|
||||
.action-bar { --action-slot-size: 36px; bottom: 19px; width: min(360px, calc(100vw - 18px)); }
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import {
|
||||
LONG_TERM_AURA_DURATION_MS,
|
||||
auraDisplayName,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIcon,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
} from "./AuraStrip";
|
||||
|
||||
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
return {
|
||||
@@ -12,6 +19,22 @@ function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function activeAura(definition: AuraDefinition, remainingAbsorb = 0): ActiveAura {
|
||||
return {
|
||||
instanceId: "test-aura-instance",
|
||||
definition,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
appliedAt: 1_000,
|
||||
expiresAt: definition.durationMs === null ? null : 1_000 + definition.durationMs,
|
||||
stacks: 1,
|
||||
absorbs: remainingAbsorb > 0
|
||||
? [{ id: "test-absorb", remaining: remainingAbsorb }]
|
||||
: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("aura strip visibility", () => {
|
||||
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
|
||||
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
|
||||
@@ -24,4 +47,28 @@ describe("aura strip visibility", () => {
|
||||
);
|
||||
expect(auraDefinitionIcon(aura())).toBeNull();
|
||||
});
|
||||
|
||||
it("removes internal DBC and aura identifiers from player-facing names", () => {
|
||||
expect(auraDisplayName(aura({ name: "Power Word: Fortitude (DBC 1243, Aura 29)" }))).toBe(
|
||||
"Power Word: Fortitude",
|
||||
);
|
||||
expect(auraDisplayName(aura({ name: "Arcane Intellect (Aura 13)" }))).toBe(
|
||||
"Arcane Intellect",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies permanent and one-minute effects as long-term without using remaining time", () => {
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: null }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers authored tooltip copy and explains absorb-only fallback auras", () => {
|
||||
expect(auraDescription(activeAura(aura({ description: "Raises armor by 10%." })))).toBe(
|
||||
"Raises armor by 10%.",
|
||||
);
|
||||
expect(auraDescription(activeAura(aura({
|
||||
absorbs: [{ id: "test-absorb", amount: 250 }],
|
||||
}), 175))).toBe("Absorbs up to 175 incoming damage.");
|
||||
});
|
||||
});
|
||||
+75
-6
@@ -1,12 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
import type { ActiveAura, AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
||||
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
|
||||
|
||||
export const LONG_TERM_AURA_DURATION_MS = 60_000;
|
||||
|
||||
interface AuraStripProps {
|
||||
readonly targetId: string;
|
||||
readonly targetIds?: readonly string[];
|
||||
readonly dispositions?: readonly AuraDisposition[];
|
||||
readonly className?: string;
|
||||
readonly hideLongTerm?: boolean;
|
||||
readonly showOnlyLongTerm?: boolean;
|
||||
readonly targetLabels?: Readonly<Record<string, string>>;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}
|
||||
|
||||
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
|
||||
@@ -17,17 +26,55 @@ export function auraDefinitionIcon(definition: AuraDefinition): string | null {
|
||||
return definition.icon?.trim() || null;
|
||||
}
|
||||
|
||||
export function auraDisplayName(definition: AuraDefinition): string {
|
||||
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
|
||||
}
|
||||
|
||||
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
|
||||
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
|
||||
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
|
||||
}
|
||||
|
||||
export function auraDescription(aura: ActiveAura): string {
|
||||
const authored = aura.definition.description?.trim();
|
||||
if (authored) return authored;
|
||||
|
||||
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
|
||||
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
|
||||
if (aura.definition.controlImmunities?.length) {
|
||||
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
|
||||
}
|
||||
if (aura.definition.modifiers?.length) {
|
||||
return aura.definition.disposition === "buff"
|
||||
? "Improves combat attributes while active."
|
||||
: "Reduces combat effectiveness while active.";
|
||||
}
|
||||
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
|
||||
return aura.definition.disposition === "buff"
|
||||
? "A beneficial effect is active."
|
||||
: "A harmful effect is active.";
|
||||
}
|
||||
|
||||
export function AuraStrip({
|
||||
targetId,
|
||||
targetIds,
|
||||
dispositions = ["buff", "debuff"],
|
||||
className = "",
|
||||
hideLongTerm = false,
|
||||
showOnlyLongTerm = false,
|
||||
targetLabels,
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: AuraStripProps) {
|
||||
const allAuras = useCombatStore((state) => state.auras);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const auras = allAuras.filter((aura) => (
|
||||
aura.targetId === targetId
|
||||
requestedTargetIds.includes(aura.targetId)
|
||||
&& dispositions.includes(aura.definition.disposition)
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
));
|
||||
|
||||
@@ -44,14 +91,36 @@ export function AuraStrip({
|
||||
{auras.map((aura) => {
|
||||
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
||||
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
||||
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
||||
const displayName = auraDisplayName(aura.definition);
|
||||
const targetLabel = targetLabels?.[aura.targetId];
|
||||
const initial = displayName.charAt(0).toUpperCase() || "*";
|
||||
const icon = auraDefinitionIcon(aura.definition);
|
||||
const inspection: StatusEffectInspection = {
|
||||
id: aura.instanceId,
|
||||
name: displayName,
|
||||
disposition: aura.definition.disposition,
|
||||
description: auraDescription(aura),
|
||||
remainingLabel: timer ? `${timer} remaining` : "Permanent",
|
||||
stacks: aura.stacks,
|
||||
...(targetLabel ? { targetLabel } : {}),
|
||||
...(aura.definition.dispelCategory
|
||||
? { dispelCategory: aura.definition.dispelCategory }
|
||||
: {}),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={aura.instanceId}
|
||||
className={`aura aura--${aura.definition.disposition}`}
|
||||
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
|
||||
tabIndex={onInspect ? 0 : undefined}
|
||||
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
aria-describedby={onInspect ? tooltipId : undefined}
|
||||
title={onInspect ? undefined : `${displayName}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onPointerLeave={onInspect ? (event) => {
|
||||
if (event.currentTarget !== document.activeElement) onInspect(null);
|
||||
} : undefined}
|
||||
onFocus={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onBlur={onInspect ? () => onInspect(null) : undefined}
|
||||
>
|
||||
{icon
|
||||
? <img className="aura__icon" src={icon} alt="" draggable={false} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { normalizePreviewFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
const CharacterPreview3D = lazy(() => import("./CharacterPreview3D"));
|
||||
|
||||
@@ -33,6 +34,9 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
const characterClass = classById(source.classId);
|
||||
const name = "name" in source && source.name ? source.name : "New adventurer";
|
||||
const rotation = "rotation" in source ? source.rotation : -8;
|
||||
const previewFrameRate = normalizePreviewFrameRateLimit(
|
||||
"settings" in source ? source.settings?.previewFrameRate : undefined,
|
||||
);
|
||||
const style = {
|
||||
"--race-accent": race.accent,
|
||||
"--class-color": characterClass.color,
|
||||
@@ -45,7 +49,7 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />
|
||||
) : (
|
||||
<Suspense fallback={<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />}>
|
||||
<CharacterPreview3D source={source} />
|
||||
<CharacterPreview3D source={source} frameRateLimit={previewFrameRate} />
|
||||
</Suspense>
|
||||
)}
|
||||
<span className="sr-only">{source.gender} {race.name} {characterClass.name} preview</span>
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import {
|
||||
numericFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "../scene/CappedFrameLoop";
|
||||
|
||||
function FittedCharacter({
|
||||
characterClassColor,
|
||||
@@ -30,19 +36,34 @@ function FittedCharacter({
|
||||
);
|
||||
}
|
||||
|
||||
export default function CharacterPreview3D({ source }: {
|
||||
export default function CharacterPreview3D({ frameRateLimit, source }: {
|
||||
frameRateLimit: PreviewFrameRateLimit;
|
||||
source: CharacterProfile | CharacterDraft;
|
||||
}) {
|
||||
const race = raceById(source.raceId);
|
||||
const characterClass = classById(source.classId);
|
||||
const turnDegrees = "rotation" in source ? source.rotation : -8;
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const targetFps = numericFrameRateLimit(frameRateLimit);
|
||||
const frameloop = !documentVisible
|
||||
? "demand"
|
||||
: targetFps === null ? "always" : "never";
|
||||
return (
|
||||
<div className="character-preview__viewport" aria-hidden="true">
|
||||
<div
|
||||
className="character-preview__viewport"
|
||||
aria-hidden="true"
|
||||
data-frame-rate-limit={frameRateLimit}
|
||||
data-frame-loop={frameloop}
|
||||
>
|
||||
<Canvas
|
||||
dpr={[1, 1.25]}
|
||||
frameloop={frameloop}
|
||||
camera={{ fov: 32, near: 0.05, far: 40, position: [3.2, 1.8, 4.6] }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "default" }}
|
||||
>
|
||||
{targetFps !== null && frameloop === "never" && (
|
||||
<CappedFrameLoop active={documentVisible} targetFps={targetFps} />
|
||||
)}
|
||||
<ambientLight intensity={1.15} color="#d8e2ca" />
|
||||
<hemisphereLight args={["#d8e9ce", "#142019", 1.8]} />
|
||||
<directionalLight position={[3, 5, 4]} intensity={2.1} color="#f0e5bf" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useGameStore } from "../game/store";
|
||||
import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { ExperienceBar } from "./ExperienceBar";
|
||||
import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { PlayerFrame } from "./PlayerFrame";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
@@ -344,6 +345,7 @@ export function Hud() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="hud-top-right">
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
@@ -352,6 +354,8 @@ export function Hud() {
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<PlayerStatusPanel now={clock} />
|
||||
</div>
|
||||
|
||||
{target && settings.showTargetFrame && (
|
||||
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
||||
|
||||
+37
-1
@@ -11,6 +11,14 @@ import {
|
||||
type CombatPresentationSettings,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
|
||||
export interface OptionsPanelSettings {
|
||||
showMobHealthBars: boolean;
|
||||
@@ -24,6 +32,8 @@ export interface OptionsPanelSettings {
|
||||
masterVolume: number;
|
||||
uiScale: number;
|
||||
minimapRotation: "north-up" | "player-up";
|
||||
gameplayFrameRate: GameplayFrameRateLimit;
|
||||
previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps {
|
||||
@@ -87,7 +97,15 @@ function OptionsDialog({
|
||||
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
|
||||
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
|
||||
};
|
||||
const cycleGameplayFrameRate = () => onChange({
|
||||
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const cyclePreviewFrameRate = () => onChange({
|
||||
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
|
||||
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
|
||||
...TOGGLES.map((option) => ({
|
||||
id: `option-${option.id}`,
|
||||
run: () => onChange({ [option.id]: !settings[option.id] }),
|
||||
@@ -141,6 +159,24 @@ function OptionsDialog({
|
||||
</header>
|
||||
|
||||
<div className="options-panel__list">
|
||||
<h3 className="options-panel__section-title">Performance</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>Gameplay frame rate</strong><small>Limit 3D gameplay rendering without changing combat or physics timing.</small></span>
|
||||
{optionButton(
|
||||
"option-gameplay-frame-rate",
|
||||
frameRateLimitLabel(settings.gameplayFrameRate),
|
||||
cycleGameplayFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Character preview frame rate</strong><small>Limit animated character previews shown in menus.</small></span>
|
||||
{optionButton(
|
||||
"option-preview-frame-rate",
|
||||
frameRateLimitLabel(settings.previewFrameRate),
|
||||
cyclePreviewFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Gameplay and interface</h3>
|
||||
{TOGGLES.map((option) => (
|
||||
<div key={option.id} className="option-row">
|
||||
<span><strong>{option.label}</strong><small>{option.description}</small></span>
|
||||
@@ -185,7 +221,7 @@ function OptionsDialog({
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Combat presentation</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>VFX quality</strong><small>High uses the full particle budget; Low reduces particles and lights; Off skips combat VFX.</small></span>
|
||||
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
|
||||
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
|
||||
</div>
|
||||
{COMBAT_ACTOR_GROUPS.map((group) => (
|
||||
|
||||
@@ -149,7 +149,7 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
<i style={{ width: `${resourcePercent}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -273,8 +273,12 @@ export function PartyFrames() {
|
||||
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
|
||||
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={[null, PLAYER_AGGRO_ID]}
|
||||
className="timed-effect-strip--party"
|
||||
/>
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
{onlineMembers.map((member) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
|
||||
function activeAura(
|
||||
targetId: string,
|
||||
durationMs: number | null,
|
||||
expiresAt: number | null = durationMs === null ? null : 10_000 + durationMs,
|
||||
hideFromAuraStrip = false,
|
||||
): ActiveAura {
|
||||
const definition: AuraDefinition = {
|
||||
id: `aura-${targetId}`,
|
||||
name: "Test Aura",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
...(hideFromAuraStrip ? { hideFromAuraStrip: true } : {}),
|
||||
};
|
||||
return {
|
||||
instanceId: `instance-${targetId}`,
|
||||
definition,
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId,
|
||||
appliedAt: 10_000,
|
||||
expiresAt,
|
||||
stacks: 1,
|
||||
absorbs: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("top-right status visibility", () => {
|
||||
const now = 20_000;
|
||||
const partyTargets = new Set(["party-1"]);
|
||||
|
||||
it("shows every player aura and centralizes long-term party auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now, partyTargets)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import {
|
||||
AuraStrip,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
auraDisplayName,
|
||||
} from "./AuraStrip";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
type StatusEffectInspection,
|
||||
} from "./statusEffectInspection";
|
||||
|
||||
const PLAYER_EFFECT_TARGETS: readonly (string | null)[] = [null, PLAYER_AGGRO_ID];
|
||||
const TOOLTIP_ID = "player-status-effect-tooltip";
|
||||
|
||||
function statusLabel(disposition: StatusEffectInspection["disposition"]): string {
|
||||
return disposition === "buff" ? "Buff" : "Debuff";
|
||||
}
|
||||
|
||||
export function auraDisplaysInPlayerStatusPanel(
|
||||
aura: ActiveAura,
|
||||
now: number,
|
||||
partyTargetIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const belongsInPanel = aura.targetId === PLAYER_AGGRO_ID
|
||||
|| (partyTargetIds.has(aura.targetId) && auraDefinitionIsLongTerm(aura.definition));
|
||||
return belongsInPanel
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now);
|
||||
}
|
||||
|
||||
export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
const auras = useCombatStore((state) => state.auras);
|
||||
const effects = useCombatStore((state) => state.effects);
|
||||
const shield = useCombatStore((state) => state.shield);
|
||||
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
|
||||
|
||||
const partyAuraTargetIds = partyMembers.map((member) => member.id);
|
||||
const partyAuraTargetIdSet = new Set(partyAuraTargetIds);
|
||||
const partyAuraTargetLabels = Object.fromEntries(
|
||||
partyMembers.map((member) => [member.id, member.name]),
|
||||
);
|
||||
const activeAuras = auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
|
||||
));
|
||||
const activeEffects = effects.filter((effect) => (
|
||||
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
|
||||
&& timedEffectRemainingMs(effect, now) > 0
|
||||
));
|
||||
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
|
||||
const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length
|
||||
+ activeEffects.filter((effect) => effect.kind === "hot").length
|
||||
+ (shieldActive ? 1 : 0);
|
||||
const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length
|
||||
+ activeEffects.filter((effect) => effect.kind === "dot").length;
|
||||
const activeInspectionIds = new Set([
|
||||
...activeAuras.map((aura) => aura.instanceId),
|
||||
...activeEffects.map((effect) => effect.id),
|
||||
...(shieldActive ? ["player-absorb-shield"] : []),
|
||||
]);
|
||||
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
|
||||
const shieldInspection: StatusEffectInspection = {
|
||||
id: "player-absorb-shield",
|
||||
name: "Absorb Shield",
|
||||
disposition: "buff",
|
||||
description: `Absorbs up to ${Math.ceil(shield)} incoming damage before health is lost.`,
|
||||
remainingLabel: shieldExpiresAt > now
|
||||
? `${formatTimedEffectTimer(shieldExpiresAt - now)} remaining`
|
||||
: "Until depleted",
|
||||
stacks: 1,
|
||||
};
|
||||
const liveInspection = inspected && inspectedIsActive
|
||||
? (() => {
|
||||
if (inspected.id === shieldInspection.id) return shieldInspection;
|
||||
const aura = activeAuras.find((candidate) => candidate.instanceId === inspected.id);
|
||||
if (aura) {
|
||||
const remaining = aura.expiresAt === null
|
||||
? "Permanent"
|
||||
: `${formatTimedEffectTimer(Math.max(0, aura.expiresAt - now))} remaining`;
|
||||
return {
|
||||
...inspected,
|
||||
name: auraDisplayName(aura.definition),
|
||||
disposition: aura.definition.disposition,
|
||||
description: auraDescription(aura),
|
||||
remainingLabel: remaining,
|
||||
stacks: aura.stacks,
|
||||
...(aura.definition.dispelCategory
|
||||
? { dispelCategory: aura.definition.dispelCategory }
|
||||
: {}),
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
const effect = activeEffects.find((candidate) => candidate.id === inspected.id);
|
||||
if (effect) {
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
return {
|
||||
...inspected,
|
||||
name: ability?.name ?? effect.sourceAbilityId,
|
||||
disposition: effect.kind === "hot" ? "buff" : "debuff",
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
return inspected;
|
||||
})()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (inspected && !inspectedIsActive) setInspected(null);
|
||||
}, [inspected, inspectedIsActive]);
|
||||
|
||||
return (
|
||||
<section className="player-status-panel" aria-label="Active buffs and debuffs">
|
||||
<div
|
||||
className="player-status-panel__section player-status-panel__section--buffs"
|
||||
aria-label={`${buffCount} active ${buffCount === 1 ? "buff" : "buffs"}`}
|
||||
>
|
||||
<header><strong>Buffs</strong><b>{buffCount}</b></header>
|
||||
<div className="player-status-panel__icons">
|
||||
{shieldActive ? (
|
||||
<span
|
||||
className="aura aura--buff aura--inspectable player-status-panel__synthetic"
|
||||
tabIndex={0}
|
||||
aria-label={`Absorb Shield, buff, ${shieldInspection.remainingLabel}`}
|
||||
aria-describedby={TOOLTIP_ID}
|
||||
onPointerEnter={() => setInspected(shieldInspection)}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.currentTarget !== document.activeElement) setInspected(null);
|
||||
}}
|
||||
onFocus={() => setInspected(shieldInspection)}
|
||||
onBlur={() => setInspected(null)}
|
||||
>
|
||||
<span className="aura__initial" aria-hidden="true">S</span>
|
||||
<b className="aura__timer" aria-hidden="true">
|
||||
{shieldExpiresAt > now ? formatTimedEffectTimer(shieldExpiresAt - now) : ""}
|
||||
</b>
|
||||
</span>
|
||||
) : null}
|
||||
<AuraStrip
|
||||
targetId={PLAYER_AGGRO_ID}
|
||||
dispositions={["buff"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["buff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
kinds={["hot"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{!buffCount ? <span className="player-status-panel__empty">None active</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="player-status-panel__section player-status-panel__section--debuffs"
|
||||
aria-label={`${debuffCount} active ${debuffCount === 1 ? "debuff" : "debuffs"}`}
|
||||
>
|
||||
<header><strong>Debuffs</strong><b>{debuffCount}</b></header>
|
||||
<div className="player-status-panel__icons">
|
||||
<AuraStrip
|
||||
targetId={PLAYER_AGGRO_ID}
|
||||
dispositions={["debuff"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["debuff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
kinds={["dot"]}
|
||||
className="player-status-panel__strip"
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
{!debuffCount ? <span className="player-status-panel__empty">None active</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveInspection ? (
|
||||
<div
|
||||
id={TOOLTIP_ID}
|
||||
className={`player-status-tooltip player-status-tooltip--${liveInspection.disposition}`}
|
||||
role="tooltip"
|
||||
>
|
||||
<header>
|
||||
<strong>{liveInspection.name}</strong>
|
||||
<span>{statusLabel(liveInspection.disposition)}</span>
|
||||
</header>
|
||||
<p>{liveInspection.description}</p>
|
||||
<footer>
|
||||
<span>{liveInspection.remainingLabel}</span>
|
||||
{liveInspection.stacks > 1 ? <span>{liveInspection.stacks} stacks</span> : null}
|
||||
{liveInspection.dispelCategory ? <span>{liveInspection.dispelCategory}</span> : null}
|
||||
{liveInspection.targetLabel ? <span>Target: {liveInspection.targetLabel}</span> : null}
|
||||
</footer>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { timedEffectDescription } from "./statusEffectInspection";
|
||||
|
||||
describe("timed effect tooltip copy", () => {
|
||||
it("uses the source ability description when one is available", () => {
|
||||
expect(timedEffectDescription("hot", " Restores health over 12 sec. ")).toBe(
|
||||
"Restores health over 12 sec.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes periodic healing and damage when source copy is unavailable", () => {
|
||||
expect(timedEffectDescription("hot")).toContain("Restores health");
|
||||
expect(timedEffectDescription("dot")).toContain("Deals damage");
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,33 @@ import {
|
||||
formatTimedEffectTimer,
|
||||
timedEffectRemainingMs,
|
||||
} from "../game/timedEffectDisplay";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
type StatusEffectInspectHandler,
|
||||
type StatusEffectInspection,
|
||||
} from "./statusEffectInspection";
|
||||
|
||||
export type TimedEffectKind = TimedCombatEffect["kind"];
|
||||
|
||||
interface TimedEffectStripProps {
|
||||
readonly targetId: string | null;
|
||||
readonly targetIds?: readonly (string | null)[];
|
||||
readonly kinds?: readonly TimedEffectKind[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}
|
||||
|
||||
function LiveTimedEffectStrip({
|
||||
effects,
|
||||
className = "",
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: {
|
||||
readonly effects: readonly TimedCombatEffect[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
@@ -45,12 +57,29 @@ function LiveTimedEffectStrip({
|
||||
{visibleEffects.map(({ effect, ability, remainingMs }) => {
|
||||
const label = ability?.name ?? effect.sourceAbilityId;
|
||||
const timer = formatTimedEffectTimer(remainingMs);
|
||||
const disposition = effect.kind === "hot" ? "buff" : "debuff";
|
||||
const inspection: StatusEffectInspection = {
|
||||
id: effect.id,
|
||||
name: label,
|
||||
disposition,
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${timer} remaining`,
|
||||
stacks: 1,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={effect.id}
|
||||
className={`timed-effect timed-effect--${effect.kind}`}
|
||||
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
|
||||
tabIndex={onInspect ? 0 : undefined}
|
||||
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time, ${timer} remaining`}
|
||||
title={`${label} · ${timer} remaining`}
|
||||
aria-describedby={onInspect ? tooltipId : undefined}
|
||||
title={onInspect ? undefined : `${label} · ${timer} remaining`}
|
||||
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onPointerLeave={onInspect ? (event) => {
|
||||
if (event.currentTarget !== document.activeElement) onInspect(null);
|
||||
} : undefined}
|
||||
onFocus={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onBlur={onInspect ? () => onInspect(null) : undefined}
|
||||
>
|
||||
{ability?.icon
|
||||
? <img src={ability.icon} alt="" draggable={false} />
|
||||
@@ -69,14 +98,25 @@ function LiveTimedEffectStrip({
|
||||
*/
|
||||
export function TimedEffectStrip({
|
||||
targetId,
|
||||
targetIds,
|
||||
kinds = ["dot", "hot"],
|
||||
className,
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: TimedEffectStripProps) {
|
||||
const allEffects = useCombatStore((state) => state.effects);
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const effects = allEffects.filter((effect) => (
|
||||
effect.targetId === targetId && kinds.includes(effect.kind)
|
||||
requestedTargetIds.includes(effect.targetId) && kinds.includes(effect.kind)
|
||||
));
|
||||
|
||||
if (!effects.length) return null;
|
||||
return <LiveTimedEffectStrip effects={effects} className={className} />;
|
||||
return (
|
||||
<LiveTimedEffectStrip
|
||||
effects={effects}
|
||||
className={className}
|
||||
onInspect={onInspect}
|
||||
tooltipId={tooltipId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuraDisposition, DispelCategory } from "../game/combatAuras";
|
||||
|
||||
export interface StatusEffectInspection {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
readonly description: string;
|
||||
readonly remainingLabel: string;
|
||||
readonly stacks: number;
|
||||
readonly targetLabel?: string;
|
||||
readonly dispelCategory?: DispelCategory;
|
||||
}
|
||||
|
||||
export type StatusEffectInspectHandler = (effect: StatusEffectInspection | null) => void;
|
||||
|
||||
export function timedEffectDescription(
|
||||
kind: "dot" | "hot",
|
||||
authoredDescription?: string,
|
||||
): string {
|
||||
const authored = authoredDescription?.trim();
|
||||
if (authored) return authored;
|
||||
return kind === "hot"
|
||||
? "Restores health periodically while active."
|
||||
: "Deals damage periodically while active.";
|
||||
}
|
||||
Reference in new issue
Block a user