forked from phenom/healer-man
Release Healer Man 0.1.37
This commit is contained in:
1 parent
03a4e3f51c
commit
361734bc41
41 files changed
+1714
-110
No files matched your search
@@ -2439,3 +2439,122 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
tests remained skipped. Final production TypeScript/Vite compilation, texture
|
||||||
and performance audits, static compression, and bundle verification passed.
|
and performance audits, static compression, and bundle verification passed.
|
||||||
Existing chunk-size advisories remain. No hosted release was published.
|
Existing chunk-size advisories remain. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - HoTs and regeneration cannot heal corpses
|
||||||
|
|
||||||
|
- Reproduced the reported death-with-HoT state through lethal damage followed
|
||||||
|
by overdue Rejuvenation ticks; the original implementation restored health
|
||||||
|
while retaining the death animation. Separate failures confirmed passive
|
||||||
|
spirit/equipment regeneration, ground healing, triggered healing procs, and
|
||||||
|
a lingering damage tick's level-up refill could also restore corpse health.
|
||||||
|
- Kept the fix in combat simulation rather than scene movement. Ordinary
|
||||||
|
player healing in mutable combat work now uses a shared living-target guard;
|
||||||
|
passive regeneration preserves zero health. Timed effects validate their
|
||||||
|
target before ticks and procs, discard HoTs on dead targets even before the
|
||||||
|
next tick, and retain HoTs on living allies when their caster dies. Newly
|
||||||
|
applied actor HoTs also reject dead targets. Ground healing skips corpses
|
||||||
|
while continuing to heal living teammates. Experience may still be awarded
|
||||||
|
after death, but its level-up refill no longer implicitly resurrects.
|
||||||
|
- Explicit player and actor-based party resurrection remain available and
|
||||||
|
synchronize restored actor health/alive state. Party resurrection also
|
||||||
|
clears the death animation using the existing ability-cancel event. Normal
|
||||||
|
checkpoint respawn still restores health and clears lingering effects.
|
||||||
|
The existing player movement gate remains unchanged and blocks zero health.
|
||||||
|
- Fifteen new regressions cover the original self-HoT failure, actual Priest
|
||||||
|
Renew, overdue/future ticks, explicit companion-to-player HoTs and their
|
||||||
|
healing logs, dead allies, regeneration, ground healing, proc healing,
|
||||||
|
post-death level rewards, living-target healing, resurrection, and respawn.
|
||||||
|
The final focused combat/death/support checks passed 101 tests; the full
|
||||||
|
suite passed all 230 files and 1,459 tests with six existing skips.
|
||||||
|
- No live corpse-movement replay or physical Android check was performed in
|
||||||
|
this pass. Evidence is combat-state regression coverage, not a new visual
|
||||||
|
claim. Character rendering, camera/input, dungeon assets, lighting, and
|
||||||
|
navigation policy were not modified. No hosted release was published.
|
||||||
|
|
||||||
|
## 2026-09-12 - Selectable resurrection checkpoints and fallen NPC recovery
|
||||||
|
|
||||||
|
- Normal dungeons now offer the entrance plus every defeated boss as named
|
||||||
|
resurrection destinations. Choices use authored boss locations, not corpse
|
||||||
|
coordinates, and unlock from actual defeated actors or durable encounter
|
||||||
|
objective completion. Alive/script-despawned bosses and invalid destination
|
||||||
|
IDs cannot grant a checkpoint. Boss-wipe resets retain completed checkpoints;
|
||||||
|
a fresh dungeon run clears its progress.
|
||||||
|
- The player defeat prompt includes a destination chooser. Respawning at the
|
||||||
|
entrance or a cleared boss retains defeated enemies and run progress while
|
||||||
|
resetting the current pull and restoring the party through the existing
|
||||||
|
respawn lifecycle. Legacy callers without a choice retain saved-spawn behavior.
|
||||||
|
- A contextual `Rez dead members` button and matching destination chooser appear
|
||||||
|
below the party frames whenever an NPC teammate is dead. Only fallen NPCs are
|
||||||
|
revived, at full health, into their formation slots at the selected checkpoint.
|
||||||
|
Living NPCs, the player's health/location, current enemies, and party orders
|
||||||
|
are not reset. Lingering timed effects on revived NPCs are removed without
|
||||||
|
removing living allies' HoTs. Manastorm charges and remote human resurrection
|
||||||
|
behavior are unchanged; non-authority online clients cannot mutate NPC revival.
|
||||||
|
- Living, engaged bosses disable the NPC chooser/button with an explanation;
|
||||||
|
the same restriction is checked again when the action executes. Trash combat
|
||||||
|
does not block it. Individual return tasks use approved navigation toward the
|
||||||
|
player's current location, including while the party is on Stop, then resume
|
||||||
|
the current order. Returning members do not cast/taunt from the checkpoint or
|
||||||
|
skew the engaged squad's target-selection center. Existing validated stuck
|
||||||
|
recovery remains available. Recall, death, actor unmount, and run reset cancel
|
||||||
|
pending return tasks. Scene relocation clears the corpse animation explicitly.
|
||||||
|
- Live localhost Stockades screenshots at 1280x720 showed the panel with a dead
|
||||||
|
Morvane tank, an enabled entrance chooser/button during trash combat, and the
|
||||||
|
tank restored to 1707 health at the entrance after clicking while Stop remained
|
||||||
|
selected. The button disappeared when no NPC was dead; living Thorne continued
|
||||||
|
taking damage rather than receiving a party-wide refill. At 384x844 both native
|
||||||
|
chooser and button remained within the viewport, about 176px wide and 44px high.
|
||||||
|
The narrow screenshot capture was backend-scaled, so DOM bounds supplemented
|
||||||
|
the visual check. Normal viewport sizing was restored and the temporary tab
|
||||||
|
and development server were closed.
|
||||||
|
- Twenty-nine new regressions cover checkpoint unlocking/retention, invalid and
|
||||||
|
locked choices, player checkpoint respawn, boss/trash restrictions, selective
|
||||||
|
NPC revival/effect cleanup, authority/Manastorm isolation, Stop/rejoin behavior,
|
||||||
|
wall-aware return navigation, registry lifecycle, and UI visibility/placement.
|
||||||
|
The shipping Stockades navigation/collision test also simulates all four NPC
|
||||||
|
formation slots walking from the entrance and every boss checkpoint back to
|
||||||
|
the entrance through approved corridors, without requiring stuck recovery.
|
||||||
|
Final Vitest validation passed all 234 files and 1,488 tests, with six existing
|
||||||
|
skips. No live boss-kill destination-selection, distant-player return replay,
|
||||||
|
multiplayer session, or physical Android check is claimed in this pass.
|
||||||
|
- Browser logs contained no errors; Rapier initialization and KTX2-loader warnings
|
||||||
|
occurred during development hot reloads. Character assets, camera/input,
|
||||||
|
dungeon geometry, and lighting were not edited. Final production TypeScript/Vite
|
||||||
|
compilation, static compression, and release-bundle verification passed; texture
|
||||||
|
and environment-performance audits also passed during prebuild. Existing build
|
||||||
|
chunk advisories remain. No hosted release was published.
|
||||||
|
|
||||||
|
## Optional DPS meter — September 12, 2026
|
||||||
|
|
||||||
|
- Added a compact party DPS meter with current/last-fight damage, class-colored
|
||||||
|
rankings, and a shared fight timer. It consumes the lossless combat event
|
||||||
|
stream and counts effective damage, including pets/summons under their owner.
|
||||||
|
Completed fights remain visible until the next pull; encounter resets clear
|
||||||
|
the meter. Hiding it does not stop recording. Local menu/background pauses
|
||||||
|
do not dilute DPS.
|
||||||
|
- Automatic placement uses the PC lower-left corner and the Thor companion
|
||||||
|
display. The UI editor supports visibility, dragging/keyboard nudges, width,
|
||||||
|
and screen selection. These settings participate in saved layouts, all six
|
||||||
|
template slots, and export/import, including migration of older UI files.
|
||||||
|
Dedicated companion snapshots carry meter data and draft placement; lower
|
||||||
|
display movement commands update the authoritative editor draft.
|
||||||
|
- Live localhost Stockades checks at 1280x720 showed increasing party damage
|
||||||
|
and readable bars in the PC lower-left corner. Resizing from 230 to 240 pixels
|
||||||
|
worked; dragging moved the handle from approximately (9, 509) to (269, 400).
|
||||||
|
Saving visibility off hid the meter, and reopening the editor retained that
|
||||||
|
setting while damage continued accumulating. Default position/width and
|
||||||
|
visibility were restored after this check.
|
||||||
|
- In Thor preview, the meter appeared only inside the 620x540 companion surface.
|
||||||
|
Populated bars remained clear of the party strip, vital controls, and action
|
||||||
|
bar. With the main editor open, dragging on the bottom screen moved its handle
|
||||||
|
from approximately (526, 322) to (661, 369) within companion bounds. Those
|
||||||
|
preview movement edits were canceled. Screenshots are saved in
|
||||||
|
`artifacts/dps-meter/pc-default.png`, `thor-default.png`, and
|
||||||
|
`thor-editor-moved.png`. Full-page capture scaled incorrectly in the browser
|
||||||
|
backend, so the Thor screenshots use the scrolled normal viewport.
|
||||||
|
- All 47 targeted tests passed across seven files, including damage accounting,
|
||||||
|
ownership, reset/pause behavior, UI migration/persistence, and dedicated
|
||||||
|
companion publication/unsubscription. The production build and release
|
||||||
|
checks passed. Browser error logs were empty. Physical Thor hardware and a
|
||||||
|
multiplayer session were not exercised; the new per-party totals consume
|
||||||
|
this client's combat stream. No hosted release was published.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.36",
|
"versionName": "0.1.37",
|
||||||
"versionCode": 1036
|
"versionCode": 1037
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import "./ui/uiLayout.css";
|
|||||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||||
import { CombatBridge } from "./game/CombatBridge";
|
import { CombatBridge } from "./game/CombatBridge";
|
||||||
|
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||||
import { useCombatStore } from "./game/combatStore";
|
import { useCombatStore } from "./game/combatStore";
|
||||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||||
@@ -93,6 +94,7 @@ function InputBridge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GameRuntime() {
|
export default function GameRuntime() {
|
||||||
|
useEffect(connectDpsMeter, []);
|
||||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
|
||||||
|
|||||||
+62
-57
@@ -2175,10 +2175,7 @@ function executeTriggeredAuraProcsInWork(
|
|||||||
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
||||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
||||||
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
||||||
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, action.amount);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
||||||
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
||||||
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
||||||
@@ -2379,7 +2376,8 @@ function grantMobExperience(
|
|||||||
work.secondaryClassId,
|
work.secondaryClassId,
|
||||||
work.auras,
|
work.auras,
|
||||||
);
|
);
|
||||||
progression.health = progression.maxHealth;
|
// A lingering DoT may grant experience after death, but leveling is not resurrection.
|
||||||
|
progression.health = work.health > 0 ? progression.maxHealth : 0;
|
||||||
work.health = progression.health;
|
work.health = progression.health;
|
||||||
work.maxHealth = progression.maxHealth;
|
work.maxHealth = progression.maxHealth;
|
||||||
work.maxResource = talentAdjustedMaxResource(
|
work.maxResource = talentAdjustedMaxResource(
|
||||||
@@ -2877,10 +2875,7 @@ function executeTriggeredSpellInWork(
|
|||||||
"taken",
|
"taken",
|
||||||
));
|
));
|
||||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||||
const healed = Math.min(amount, work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, amount);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
} else {
|
} else {
|
||||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
@@ -3188,6 +3183,18 @@ function applyAuraHealthTransitionInWork(
|
|||||||
mirrorCompanionActor(nextActor);
|
mirrorCompanionActor(nextActor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ordinary healing never transitions a corpse back to a living actor. */
|
||||||
|
function healPlayerInWork(work: MutableCastWork, amount: number): number {
|
||||||
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||||
|
if (work.health <= 0 || requested <= 0) return 0;
|
||||||
|
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
|
||||||
|
work.health += healed;
|
||||||
|
work.progression.health = work.health;
|
||||||
|
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
|
||||||
|
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
|
||||||
|
return healed;
|
||||||
|
}
|
||||||
|
|
||||||
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
||||||
const actor = ensureCompanionActorInWork(work, id);
|
const actor = ensureCompanionActorInWork(work, id);
|
||||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||||
@@ -4700,7 +4707,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
||||||
: friendlyOnline
|
: friendlyOnline
|
||||||
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
||||||
: resolution.effectiveAmount;
|
: healPlayerInWork(work, resolution.rawAmount);
|
||||||
if (friendlySummon && healed > 0) {
|
if (friendlySummon && healed > 0) {
|
||||||
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
||||||
? { ...summon, health: summon.health + healed }
|
? { ...summon, health: summon.health + healed }
|
||||||
@@ -4710,9 +4717,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
} else if (friendlyOnline && healed > 0) {
|
} else if (friendlyOnline && healed > 0) {
|
||||||
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
||||||
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
||||||
} else if (!friendlyTarget) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
}
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
@@ -4744,10 +4748,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
||||||
));
|
));
|
||||||
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
||||||
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, directHealing);
|
||||||
work.health += beaconHealing;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += beaconHealing;
|
|
||||||
} else if (beacon) {
|
} else if (beacon) {
|
||||||
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
||||||
}
|
}
|
||||||
@@ -5331,10 +5332,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
||||||
work.resourcePools[activeResourceType] = work.resource;
|
work.resourcePools[activeResourceType] = work.resource;
|
||||||
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
||||||
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
|
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
|
||||||
}
|
}
|
||||||
} else if (effect.action === "portal") {
|
} else if (effect.action === "portal") {
|
||||||
summonSequence += 1;
|
summonSequence += 1;
|
||||||
@@ -5363,13 +5361,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||||
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
||||||
));
|
));
|
||||||
const restored = Math.min(
|
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
||||||
work.maxHealth - work.health,
|
|
||||||
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
|
||||||
);
|
|
||||||
work.health += restored;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += restored;
|
|
||||||
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5412,9 +5404,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|
|
||||||
// Judgement of Light restores 2% health when the marked target is struck.
|
// Judgement of Light restores 2% health when the marked target is struck.
|
||||||
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
||||||
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
const healed = healPlayerInWork(work,
|
||||||
work.health += healed;
|
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(work, castThreatSource, healed);
|
addHealingThreatInWork(work, castThreatSource, healed);
|
||||||
}
|
}
|
||||||
@@ -5874,10 +5865,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||||
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
||||||
if (restored <= 0) return 0;
|
if (restored <= 0) return 0;
|
||||||
|
const playerActor = state.actors[id];
|
||||||
set({
|
set({
|
||||||
health: restored,
|
health: restored,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
actors: playerActor ? { ...state.actors, [id]: {
|
||||||
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||||
|
} } : state.actors,
|
||||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
||||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
@@ -6101,7 +6097,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (!state.actors[sourceActorId]) return false;
|
if (!state.actors[sourceActorId]) return false;
|
||||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||||
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
|
if (effect.kind === "hot") {
|
||||||
|
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
|
||||||
|
: state.summons.find((summon) => summon.id === targetActorId)?.health
|
||||||
|
?? state.actors[targetActorId]?.health ?? 0;
|
||||||
|
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
|
||||||
|
}
|
||||||
const sourceActor = state.actors[sourceActorId];
|
const sourceActor = state.actors[sourceActorId];
|
||||||
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
||||||
const amount = sourceMember
|
const amount = sourceMember
|
||||||
@@ -6402,11 +6403,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const restored = Math.max(1, Math.round(
|
const restored = Math.max(1, Math.round(
|
||||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||||
));
|
));
|
||||||
|
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
||||||
set({
|
set({
|
||||||
health: restored,
|
health: restored,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||||
|
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
|
||||||
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
||||||
|
} } : state.actors,
|
||||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||||
});
|
});
|
||||||
return restored;
|
return restored;
|
||||||
@@ -7578,7 +7583,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
combatEvents: [...state.combatEvents],
|
combatEvents: [...state.combatEvents],
|
||||||
actors: { ...state.actors },
|
actors: { ...state.actors },
|
||||||
cooldowns: { ...state.cooldowns },
|
cooldowns: { ...state.cooldowns },
|
||||||
health: Math.min(
|
health: state.health <= 0 ? 0 : Math.min(
|
||||||
state.maxHealth,
|
state.maxHealth,
|
||||||
state.health + resolveAuraStatValue(
|
state.health + resolveAuraStatValue(
|
||||||
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
||||||
@@ -7707,10 +7712,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
|
||||||
|
if (effect.kind === "dot") {
|
||||||
|
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
|
||||||
|
return Boolean(mob && !mob.dead && mob.health > 0);
|
||||||
|
}
|
||||||
|
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||||
|
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
||||||
|
const summon = work.summons.find((candidate) => candidate.id === targetId);
|
||||||
|
if (summon) return summon.health > 0;
|
||||||
|
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
|
||||||
|
return Boolean(actor?.alive && actor.health > 0);
|
||||||
|
};
|
||||||
for (const effect of state.effects) {
|
for (const effect of state.effects) {
|
||||||
|
// Check before due ticks/procs, not only after applying their health changes.
|
||||||
|
if (!timedEffectTargetAlive(effect)) continue;
|
||||||
let remainingTicks = effect.remainingTicks;
|
let remainingTicks = effect.remainingTicks;
|
||||||
let nextTickAt = effect.nextTickAt;
|
let nextTickAt = effect.nextTickAt;
|
||||||
while (remainingTicks > 0 && nextTickAt <= now) {
|
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
||||||
const ability = abilityById(effect.sourceAbilityId);
|
const ability = abilityById(effect.sourceAbilityId);
|
||||||
const baseAmount = effect.fixedAmount ?? (ability
|
const baseAmount = effect.fixedAmount ?? (ability
|
||||||
? equipmentAdjustedCombatAmount(
|
? equipmentAdjustedCombatAmount(
|
||||||
@@ -7764,10 +7783,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
"taken",
|
"taken",
|
||||||
));
|
));
|
||||||
if (effect.targetId) {
|
if (effect.targetId) {
|
||||||
const target = work.actors[effect.targetId]
|
const target = ensureCompanionActorInWork(work, hotTargetId)
|
||||||
?? ensureCompanionActorInWork(work, effect.targetId)
|
?? work.actors[hotTargetId]
|
||||||
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
||||||
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
||||||
: null);
|
: null);
|
||||||
if (target?.alive) partyHotTicks.push({
|
if (target?.alive) partyHotTicks.push({
|
||||||
memberId: target.id,
|
memberId: target.id,
|
||||||
@@ -7776,9 +7795,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
sourceAbilityId: effect.sourceAbilityId,
|
sourceAbilityId: effect.sourceAbilityId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
const healed = healPlayerInWork(work, modifiedHealing);
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(
|
addHealingThreatInWork(
|
||||||
work,
|
work,
|
||||||
@@ -7823,12 +7840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
remainingTicks -= 1;
|
remainingTicks -= 1;
|
||||||
nextTickAt += effect.intervalMs;
|
nextTickAt += effect.intervalMs;
|
||||||
}
|
}
|
||||||
const targetAlive = effect.kind === "dot"
|
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
||||||
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
|
||||||
: !effect.targetId || usePartyStore.getState().members.some(
|
|
||||||
(member) => member.id === effect.targetId && member.health > 0,
|
|
||||||
);
|
|
||||||
if (remainingTicks > 0 && targetAlive) {
|
|
||||||
work.effects.push(
|
work.effects.push(
|
||||||
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
||||||
? effect
|
? effect
|
||||||
@@ -7884,13 +7896,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
||||||
|| !combatHasLineOfSight(area.position, position)) continue;
|
|| !combatHasLineOfSight(area.position, position)) continue;
|
||||||
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
||||||
|
if (member ? member.health <= 0 : work.health <= 0) continue;
|
||||||
const healed = member
|
const healed = member
|
||||||
? healCompanionActorInWork(work, friendlyId, amount)
|
? healCompanionActorInWork(work, friendlyId, amount)
|
||||||
: Math.min(amount, work.maxHealth - work.health);
|
: healPlayerInWork(work, amount);
|
||||||
if (!member) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
occurredAt: nextTickAt,
|
occurredAt: nextTickAt,
|
||||||
@@ -7941,12 +7950,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const target = injured[0];
|
const target = injured[0];
|
||||||
if (target) {
|
if (target) {
|
||||||
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
||||||
? Math.min(summon.attackDamage, work.maxHealth - work.health)
|
? healPlayerInWork(work, summon.attackDamage)
|
||||||
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
||||||
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
|
||||||
work.health += healed;
|
|
||||||
work.progression.health = work.health;
|
|
||||||
}
|
|
||||||
work.totalHealing += healed;
|
work.totalHealing += healed;
|
||||||
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilityAtLevel } from "./abilityCatalog";
|
||||||
|
import { applyAura } from "./combatAuras";
|
||||||
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
function killPlayer(): void {
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfHot(): TimedCombatEffect {
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
||||||
|
expect(effect).toBeDefined();
|
||||||
|
return effect!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMemberHealth(id: string, health: number): void {
|
||||||
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
||||||
|
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("healing must not implicitly resurrect defeated players", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
||||||
|
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
||||||
|
usePartyStore.getState().selectMember(null);
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
useGameStore.getState().resetAtEntrance();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
||||||
|
.toMatchObject({ ok: false, reason: "player-dead" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a dead player's HoT even before its next tick is due", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
||||||
|
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
||||||
|
&& ability.effects.some((effect) => effect.kind === "hot"));
|
||||||
|
expect(renew).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
||||||
|
killPlayer();
|
||||||
|
for (let frame = 0; frame < 30; frame += 1) {
|
||||||
|
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects companion HoTs with an explicit player target after death", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||||
|
useCombatStore.getState().damagePlayer(50);
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||||
|
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||||
|
&& event.effectiveAmount > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects newly applied teammate HoTs on dead targets", () => {
|
||||||
|
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
||||||
|
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
||||||
|
expect(hot.kind).toBe("hot");
|
||||||
|
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const source = usePartyStore.getState().members[0].id;
|
||||||
|
killPlayer();
|
||||||
|
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
||||||
|
const member = usePartyStore.getState().members[1];
|
||||||
|
setMemberHealth(member.id, 0);
|
||||||
|
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a fallen ally's HoT without changing other party members", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects[0];
|
||||||
|
setMemberHealth(member.id, 0);
|
||||||
|
const others = usePartyStore.getState().members.slice(1);
|
||||||
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
setMemberHealth(member.id, 1);
|
||||||
|
useCombatStore.setState({ effects: [], areaEffects: [{
|
||||||
|
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
||||||
|
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
||||||
|
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, 4_000);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||||
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a HoT on a living ally continues even when its player caster dies", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||||
|
setMemberHealth(member.id, 1);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||||
|
const effect = useCombatStore.getState().effects[0];
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
||||||
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
||||||
|
setMobPosition("dot-target", [2, 0, 0]);
|
||||||
|
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
now: 1_000, definition: {
|
||||||
|
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
||||||
|
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
||||||
|
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
||||||
|
},
|
||||||
|
}).auras;
|
||||||
|
useCombatStore.setState({ auras: [...aura], effects: [{
|
||||||
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||||
|
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, 4_000);
|
||||||
|
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
||||||
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
||||||
|
setMobPosition("dot-target", [2, 0, 0]);
|
||||||
|
useCombatStore.setState({ effects: [{
|
||||||
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||||
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||||
|
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
||||||
|
}] });
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0, 4_000);
|
||||||
|
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("living self HoTs and regeneration still restore health", () => {
|
||||||
|
useCombatStore.getState().damagePlayer(50);
|
||||||
|
const effect = selfHot();
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||||
|
expect(restored).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||||
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
||||||
|
killPlayer();
|
||||||
|
expect(respawnDefeatedPlayer()).toBe(true);
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
||||||
|
const effect = selfHot();
|
||||||
|
killPlayer();
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||||
|
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
||||||
|
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
||||||
|
expect(restored).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||||
|
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
|
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||||
import {
|
import {
|
||||||
activeDungeonCompletedObjectiveIds,
|
activeDungeonCompletedObjectiveIds,
|
||||||
resetDungeonEncounters,
|
resetDungeonEncounters,
|
||||||
@@ -11,9 +12,11 @@ import {
|
|||||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||||
*/
|
*/
|
||||||
export function respawnDefeatedPlayer(): boolean {
|
export function respawnDefeatedPlayer(checkpointId?: string): boolean {
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
if (combat.health > 0) return false;
|
if (combat.health > 0) return false;
|
||||||
|
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
|
||||||
|
if (checkpointId !== undefined && !checkpoint) return false;
|
||||||
|
|
||||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||||
@@ -31,6 +34,8 @@ export function respawnDefeatedPlayer(): boolean {
|
|||||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
}
|
}
|
||||||
|
// Select the saved return spawn without relocating first or resetting twice.
|
||||||
|
if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn });
|
||||||
useGameStore.getState().resetAfterDefeat();
|
useGameStore.getState().resetAfterDefeat();
|
||||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||||
const respawnPosition = useGameStore.getState().playerPosition;
|
const respawnPosition = useGameStore.getState().playerPosition;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage } from "./dpsMeter";
|
||||||
|
import type { StructuredCombatEvent } from "./combatEvents";
|
||||||
|
|
||||||
|
const actor = { id: "player", name: "You", color: "#fff" };
|
||||||
|
function damage(id: number, effectiveAmount = 10): StructuredCombatEvent {
|
||||||
|
return { id, occurredAt: 1_000 + id * 10, kind: "damage", sourceActorId: actor.id,
|
||||||
|
targetActorId: "enemy", rawAmount: 100, effectiveAmount, overheal: 0, absorbed: 30, critical: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DPS fight recorder", () => {
|
||||||
|
it("keeps effective damage from more than 200 events and ranks everyone over the same fight duration", () => {
|
||||||
|
let state = advanceDpsMeter(emptyDpsMeter(), 1_000, true);
|
||||||
|
for (let id = 1; id <= 1_000; id++) state = recordDpsDamage(state, damage(id), actor);
|
||||||
|
const ally = { id: "ally", name: "Ally", color: "#f00" };
|
||||||
|
state = recordDpsDamage(state, { ...damage(1_000), effectiveAmount: 5_000 }, ally);
|
||||||
|
const snapshot = dpsMeterSnapshot(state, [{ id: "healer", name: "Healer", color: "#0f0" }]);
|
||||||
|
expect(snapshot).toMatchObject({ totalDamage: 15_000, totalDps: 1_500, durationSeconds: 10 });
|
||||||
|
expect(snapshot.rows.map((row) => [row.id, row.dps, row.percent])).toEqual([
|
||||||
|
["player", 1_000, 100], ["ally", 500, 50], ["healer", 0, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("freezes the last fight, starts fresh on the next pull, and excludes menu time", () => {
|
||||||
|
let state = recordDpsDamage(emptyDpsMeter(), damage(0, 100), actor);
|
||||||
|
state = advanceDpsMeter(state, 2_000, true);
|
||||||
|
state = advanceDpsMeter(state, 12_000, true, true);
|
||||||
|
state = advanceDpsMeter(state, 13_000, false);
|
||||||
|
expect(dpsMeterSnapshot(state)).toMatchObject({ active: false, durationSeconds: 2, totalDps: 50 });
|
||||||
|
expect(advanceDpsMeter(state, 30_000, false)).toBe(state);
|
||||||
|
state = recordDpsDamage(state, { ...damage(0, 20), occurredAt: 31_000 }, actor);
|
||||||
|
expect(dpsMeterSnapshot(state)).toMatchObject({ active: true, durationSeconds: 0, totalDamage: 20, totalDps: 20 });
|
||||||
|
});
|
||||||
|
it("ignores misses, healing, invalid damage, and clock regressions", () => {
|
||||||
|
const empty = emptyDpsMeter();
|
||||||
|
expect(recordDpsDamage(empty, damage(0, 0), actor)).toBe(empty);
|
||||||
|
expect(recordDpsDamage(empty, damage(0, NaN), actor)).toBe(empty);
|
||||||
|
expect(recordDpsDamage(empty, { ...damage(0), kind: "healing" }, actor)).toBe(empty);
|
||||||
|
let state = recordDpsDamage(empty, damage(0), actor);
|
||||||
|
state = advanceDpsMeter(state, 900, true);
|
||||||
|
expect(state.elapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { StructuredCombatEvent } from "./combatEvents";
|
||||||
|
|
||||||
|
export interface DpsMeterActor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterState {
|
||||||
|
readonly startedAt: number | null;
|
||||||
|
readonly endedAt: number | null;
|
||||||
|
readonly updatedAt: number | null;
|
||||||
|
readonly elapsedMs: number;
|
||||||
|
readonly damage: Readonly<Record<string, DpsMeterActor & { total: number }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterRow extends DpsMeterActor {
|
||||||
|
readonly total: number;
|
||||||
|
readonly dps: number;
|
||||||
|
readonly percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DpsMeterSnapshot {
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly durationSeconds: number;
|
||||||
|
readonly totalDamage: number;
|
||||||
|
readonly totalDps: number;
|
||||||
|
readonly rows: readonly DpsMeterRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyDpsMeter(): DpsMeterState {
|
||||||
|
return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the last pull visible until the next starts; menus do not add combat time. */
|
||||||
|
export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState {
|
||||||
|
if (!Number.isFinite(now)) return state;
|
||||||
|
if (state.startedAt === null || state.endedAt !== null) {
|
||||||
|
return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state;
|
||||||
|
}
|
||||||
|
const updatedAt = Math.max(state.updatedAt ?? now, now);
|
||||||
|
return { ...state, updatedAt,
|
||||||
|
elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)),
|
||||||
|
endedAt: inCombat ? null : updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume the lossless stream, never the truncated combat-log history. */
|
||||||
|
export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState {
|
||||||
|
if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state;
|
||||||
|
const next = advanceDpsMeter(state, event.occurredAt, true);
|
||||||
|
return { ...next, damage: { ...next.damage,
|
||||||
|
[actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot {
|
||||||
|
const durationSeconds = state.elapsedMs / 1_000;
|
||||||
|
const seconds = Math.max(1, durationSeconds);
|
||||||
|
const damage = state.startedAt === null ? {} : {
|
||||||
|
...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage,
|
||||||
|
};
|
||||||
|
const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||||
|
const maximum = Math.max(1, ordered[0]?.total ?? 0);
|
||||||
|
const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0);
|
||||||
|
return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage,
|
||||||
|
totalDps: totalDamage / seconds,
|
||||||
|
rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { appendCombatEvent } from "./combatEvents";
|
||||||
|
import { connectDpsMeter, currentDpsMeterSnapshot, useDpsMeterStore } from "./dpsMeterStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { useUiLayoutStore } from "./uiLayoutStore";
|
||||||
|
import { currentCompanionSnapshot, executeCompanionCommand } from "../platform/dualScreenSync";
|
||||||
|
|
||||||
|
describe("live DPS tracking and companion editor", () => {
|
||||||
|
let disconnect: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||||
|
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 5 });
|
||||||
|
useCombatStore.getState().registerMob("dps-target", { name: "Target", maxHealth: 1_000_000 });
|
||||||
|
disconnect = connectDpsMeter();
|
||||||
|
});
|
||||||
|
afterEach(() => { disconnect(); useUiLayoutStore.getState().finish(false); vi.useRealTimers(); });
|
||||||
|
|
||||||
|
it("tracks real damage while hidden, caps overkill, freezes on defeat, and clears on reset", () => {
|
||||||
|
useUiLayoutStore.getState().begin();
|
||||||
|
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: false } } }));
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ totalDamage: 100, totalDps: 50 });
|
||||||
|
const remaining = useCombatStore.getState().mobs["dps-target"].health;
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 2_000_000, Date.now());
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ active: false, totalDamage: 100 + remaining });
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(currentDpsMeterSnapshot().durationSeconds).toBe(2);
|
||||||
|
useCombatStore.getState().resetEncounter();
|
||||||
|
expect(useDpsMeterStore.getState().startedAt).toBeNull();
|
||||||
|
});
|
||||||
|
it("merges summon damage into its owner and excludes hostile and friendly-target damage", () => {
|
||||||
|
const actors = useCombatStore.getState().actors;
|
||||||
|
useCombatStore.setState({ actors: { ...actors, "meter-pet": { ...actors[PLAYER_AGGRO_ID], id: "meter-pet", kind: "pet", ownerId: PLAYER_AGGRO_ID } } });
|
||||||
|
for (const [id, sourceActorId, targetActorId] of [[1, "meter-pet", "dps-target"], [2, "dps-target", PLAYER_AGGRO_ID], [3, PLAYER_AGGRO_ID, PLAYER_AGGRO_ID]] as const) {
|
||||||
|
appendCombatEvent([], { occurredAt: Date.now(), kind: "damage", sourceActorId, targetActorId,
|
||||||
|
rawAmount: 100, effectiveAmount: 80, overheal: 0, absorbed: 20, critical: false }, id);
|
||||||
|
}
|
||||||
|
expect(currentDpsMeterSnapshot().totalDamage).toBe(80);
|
||||||
|
expect(currentDpsMeterSnapshot().rows.find((row) => row.total > 0)?.id).toBe(PLAYER_AGGRO_ID);
|
||||||
|
});
|
||||||
|
it("does not dilute DPS while a local game menu pauses combat", () => {
|
||||||
|
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
useGameStore.setState({ paused: true, overlay: "ui-layout" });
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 1, totalDps: 100 });
|
||||||
|
useGameStore.setState({ paused: false, overlay: null });
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 2, totalDps: 50 });
|
||||||
|
});
|
||||||
|
it("publishes draft visibility and accepts lower-screen movement only while editing", () => {
|
||||||
|
useUiLayoutStore.getState().begin();
|
||||||
|
const command = { name: "editDpsMeter", update: { position: { x: .7, y: .8 }, width: 300 } } as const;
|
||||||
|
executeCompanionCommand(command);
|
||||||
|
expect(useUiLayoutStore.getState().draft?.elements.dps.position).toBeNull();
|
||||||
|
useGameStore.setState({ overlay: "ui-layout" });
|
||||||
|
executeCompanionCommand(command);
|
||||||
|
expect(currentCompanionSnapshot().game.dpsMeter).toMatchObject({ editing: true, layout: { position: { x: .7, y: .8 }, width: 300 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { classById, CLASSES } from "../app/characterCatalog";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { subscribeCombatEvents } from "./combatEvents";
|
||||||
|
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter";
|
||||||
|
import { onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
export const useDpsMeterStore = create(() => emptyDpsMeter());
|
||||||
|
|
||||||
|
function meterActors(): DpsMeterActor[] {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const online = useOnlineSessionStore.getState().snapshot;
|
||||||
|
return [
|
||||||
|
{ id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color },
|
||||||
|
...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })),
|
||||||
|
...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({
|
||||||
|
id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username,
|
||||||
|
color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dpsSourceActor(sourceId: string): DpsMeterActor | null {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let id = sourceId;
|
||||||
|
while (!visited.has(id)) {
|
||||||
|
visited.add(id);
|
||||||
|
if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID;
|
||||||
|
const known = meterActors().find((actor) => actor.id === id);
|
||||||
|
if (known) return known;
|
||||||
|
const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
|
||||||
|
if (!ownerId) return null;
|
||||||
|
id = ownerId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentDpsMeterSnapshot() {
|
||||||
|
return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */
|
||||||
|
export function connectDpsMeter(): () => void {
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
let suspendedPreviously = false;
|
||||||
|
const sample = () => {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
|
||||||
|
const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot);
|
||||||
|
const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden));
|
||||||
|
const state = useDpsMeterStore.getState();
|
||||||
|
const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously);
|
||||||
|
suspendedPreviously = suspended;
|
||||||
|
if (next !== state) useDpsMeterStore.setState(next, true);
|
||||||
|
};
|
||||||
|
const unsubscribeEvents = subscribeCombatEvents((event) => {
|
||||||
|
if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return;
|
||||||
|
const actor = dpsSourceActor(event.sourceActorId);
|
||||||
|
if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true);
|
||||||
|
});
|
||||||
|
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||||
|
if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) {
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
} else sample();
|
||||||
|
});
|
||||||
|
const unsubscribeGame = useGameStore.subscribe(sample);
|
||||||
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample);
|
||||||
|
const timer = setInterval(sample, 250);
|
||||||
|
sample();
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame();
|
||||||
|
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample);
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { partyRejoinMovement } from "./partyRejoin";
|
||||||
|
import { emptyPartyRouteState } from "./partyPathing";
|
||||||
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
|
||||||
|
const graph: PartyNavigationGraph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 0, position: [0, 0, 0], neighbors: [{ to: 1, cost: 6 }] },
|
||||||
|
{ id: 1, position: [0, 0, 6], neighbors: [{ to: 0, cost: 6 }, { to: 2, cost: 2 }] },
|
||||||
|
{ id: 2, position: [2, 0, 6], neighbors: [{ to: 1, cost: 2 }, { to: 3, cost: 6 }] },
|
||||||
|
{ id: 3, position: [2, 0, 0], neighbors: [{ to: 2, cost: 6 }] },
|
||||||
|
],
|
||||||
|
weldTolerance: .001, sourceVertexCount: 4, sourceTriangleCount: 2,
|
||||||
|
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("individual resurrection return navigation", () => {
|
||||||
|
it("finishes only when near the player on a clear traversable segment", () => {
|
||||||
|
expect(partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, null, emptyPartyRouteState(),
|
||||||
|
{ directSegmentAllowed: () => true }).rejoined).toBe(true);
|
||||||
|
expect(partyRejoinMovement("tank", [0, 0, 0], [0, 5, 0], 0, null, emptyPartyRouteState(),
|
||||||
|
{ directSegmentAllowed: () => true }).rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes out of a room even when the player is close on the other side of a wall", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, graph, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: (start, end) => start[0] === end[0] || (start[2] >= 5 && end[2] >= 5),
|
||||||
|
routeSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
expect(result.rejoined).toBe(false);
|
||||||
|
expect(result.blocked).toBe(false);
|
||||||
|
expect(result.waypoint).toEqual([0, 0, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the current player position, not a fixed death location or boss", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
const moved = partyRejoinMovement("tank", [4, 0, 0], [20, 0, 8], 50, null, result.route, {
|
||||||
|
directSegmentAllowed: () => true,
|
||||||
|
});
|
||||||
|
expect(moved.waypoint).toEqual([20, 0, 8]);
|
||||||
|
expect(moved.rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports blocked navigation to the existing watchdog instead of walking through walls", () => {
|
||||||
|
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed: () => false,
|
||||||
|
});
|
||||||
|
expect(result.blocked).toBe(true);
|
||||||
|
expect(result.waypoint).toBeNull();
|
||||||
|
expect(result.rejoined).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "./partyNavigation";
|
||||||
|
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteOptions, type PartyRouteState } from "./partyPathing";
|
||||||
|
import type { PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
|
export const PARTY_REJOIN_DISTANCE = 3;
|
||||||
|
|
||||||
|
/** Returning resurrected members target the player, not a distant boss or leader. */
|
||||||
|
export function partyRejoinMovement(
|
||||||
|
id: string, current: PartyWorldPosition, player: PartyWorldPosition, nowMs: number,
|
||||||
|
graph: PartyNavigationGraph | null, route: PartyRouteState,
|
||||||
|
options: Pick<PartyRouteOptions, "directSegmentAllowed" | "routeSegmentAllowed">,
|
||||||
|
) {
|
||||||
|
const reachedPlayer = (point: PartyWorldPosition) => Math.hypot(
|
||||||
|
point[0] - player[0], point[1] - player[1], point[2] - player[2],
|
||||||
|
) <= PARTY_REJOIN_DISTANCE && Boolean(options.directSegmentAllowed?.(point, player));
|
||||||
|
if (reachedPlayer(current)) return { rejoined: true, route: emptyPartyRouteState(), waypoint: null, blocked: false };
|
||||||
|
if (options.directSegmentAllowed?.(current, player)) {
|
||||||
|
return { rejoined: false, route: emptyPartyRouteState(), waypoint: player, blocked: false };
|
||||||
|
}
|
||||||
|
const destination = graph ? findNearestPartyNavigationSurface(graph, player, 5)?.position ?? player : player;
|
||||||
|
const goalKey = `member:${id}|resurrection-rejoin`;
|
||||||
|
if (partyRouteNeedsPlanning(route, goalKey, current, destination, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||||
|
route = planPartyRoute(graph, goalKey, current, destination, nowMs, {
|
||||||
|
...options, maxOriginSnapDistance: 6, maxDestinationSnapDistance: 5,
|
||||||
|
localFinishDistance: PARTY_REJOIN_DISTANCE,
|
||||||
|
destinationReached: reachedPlayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const waypoint: [number, number, number] = [0, 0, 0];
|
||||||
|
const hasWaypoint = nextPartyRouteWaypoint(route, current, waypoint)
|
||||||
|
|| nextPartyRouteRecoveryWaypoint(route, current, waypoint, options.directSegmentAllowed ?? (() => false));
|
||||||
|
return { rejoined: false, route, waypoint: hasWaypoint ? waypoint : null, blocked: route.blocked && !hasWaypoint };
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { configureOnlineSessionRuntime } from "./onlineSessionRuntime";
|
||||||
|
import { clearPartyRuntimeRegistry, consumePartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { resurrectDeadPartyMembers } from "./partyResurrection";
|
||||||
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
|
||||||
|
function killMember(index: number) {
|
||||||
|
const member = usePartyStore.getState().members[index];
|
||||||
|
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||||
|
return member.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fallen NPC checkpoint resurrection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||||
|
useCombatStore.setState({ mobs: {} });
|
||||||
|
usePartyStore.getState().initializeParty("resurrection-party", 16, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [99, 0, 99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores only fallen members at the original entrance, not the current saved spawn", () => {
|
||||||
|
const deadId = killMember(0);
|
||||||
|
usePartyStore.getState().setCommand("stop");
|
||||||
|
const living = usePartyStore.getState().members.slice(1);
|
||||||
|
const livingActors = living.map((member) => useCombatStore.getState().actors[member.id]);
|
||||||
|
const gameBefore = useGameStore.getState();
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
useGameStore.setState({ activeSpawn: { footPosition: [80, 5, 80], yaw: 1 } });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||||
|
expect(usePartyStore.getState().members[0]).toMatchObject({ health: usePartyStore.getState().members[0].maxHealth, status: "advancing" });
|
||||||
|
expect(getPartyRuntimePosition(deadId)).toEqual(derivePartySpawnSlots(dungeon.entrance, 5)[1].footPosition);
|
||||||
|
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||||
|
expect(consumePartyRuntimeRejoin(deadId)).not.toBeNull();
|
||||||
|
expect(consumePartyRuntimeRejoin(deadId)).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||||
|
expect(usePartyStore.getState().members.slice(1)).toEqual(living);
|
||||||
|
expect(living.map((member) => useCombatStore.getState().actors[member.id])).toEqual(livingActors);
|
||||||
|
expect(usePartyStore.getState().command).toBe("stop");
|
||||||
|
expect(useCombatStore.getState().health).toBe(playerBefore);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(gameBefore.playerPosition);
|
||||||
|
expect(useGameStore.getState().resetRevision).toBe(gameBefore.resetRevision);
|
||||||
|
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ kind: "resurrection", targetActorId: deadId });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores all fallen members at a killed boss without resetting any living encounter", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||||
|
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||||
|
const mobsBefore = useCombatStore.getState().mobs;
|
||||||
|
const ids = usePartyStore.getState().members.map((_, index) => killMember(index));
|
||||||
|
expect(resurrectDeadPartyMembers(`boss:${boss.id}`, 1_000)).toBe(ids.length);
|
||||||
|
const slots = derivePartySpawnSlots({ footPosition: boss.position, yaw: dungeon.entrance.yaw }, ids.length + 1);
|
||||||
|
ids.forEach((id, index) => expect(getPartyRuntimePosition(id)).toEqual(slots[index + 1].footPosition));
|
||||||
|
expect(useCombatStore.getState().mobs).toBe(mobsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces boss combat at action time and remains usable during trash combat", () => {
|
||||||
|
const id = killMember(0);
|
||||||
|
useCombatStore.getState().registerMob("active-boss", { name: "Boss", maxHealth: 100, boss: true });
|
||||||
|
useCombatStore.getState().damageMob("active-boss", 1, 100);
|
||||||
|
const before = useCombatStore.getState();
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(0);
|
||||||
|
expect(useCombatStore.getState()).toBe(before);
|
||||||
|
expect(partyRuntimeIsRejoining(id)).toBe(false);
|
||||||
|
useCombatStore.getState().damageMob("active-boss", 99, 101);
|
||||||
|
useCombatStore.getState().registerMob("trash", { name: "Trash", maxHealth: 100 });
|
||||||
|
useCombatStore.getState().damageMob("trash", 1, 102);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects locked, stale, or arbitrary checkpoint IDs without any mutation", () => {
|
||||||
|
killMember(0);
|
||||||
|
const before = useCombatStore.getState();
|
||||||
|
for (const id of [`boss:${dungeon.bosses[0].id}`, "boss:other-dungeon", "20,30,40"]) {
|
||||||
|
expect(resurrectDeadPartyMembers(id, 1_000)).toBe(0);
|
||||||
|
expect(useCombatStore.getState()).toBe(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot spend or bypass Manastorm revival charges or run on a non-authority client", () => {
|
||||||
|
killMember(0);
|
||||||
|
useGameStore.setState({ gameMode: "manastorm" });
|
||||||
|
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||||
|
useGameStore.setState({ gameMode: "dungeon" });
|
||||||
|
configureOnlineSessionRuntime({ active: true, authority: false, localAccountId: "guest" });
|
||||||
|
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", NaN)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect the dead player with the NPC button", () => {
|
||||||
|
killMember(0);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().combatEvents.at(-1)?.targetActorId).not.toBe(PLAYER_AGGRO_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes lingering effects only on the resurrected member, leaving living allies' HoTs intact", () => {
|
||||||
|
const members = usePartyStore.getState().members;
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
|
||||||
|
usePartyStore.getState().selectMember(members[0].id);
|
||||||
|
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
usePartyStore.getState().selectMember(members[1].id);
|
||||||
|
expect(useCombatStore.getState().castAbility(renew.id, undefined, 5_000).ok).toBe(true);
|
||||||
|
const livingEffects = useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id);
|
||||||
|
expect(livingEffects.length).toBeGreaterThan(0);
|
||||||
|
killMember(0);
|
||||||
|
expect(resurrectDeadPartyMembers("entrance", 6_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === members[0].id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id)).toEqual(livingEffects);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||||
|
import { onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||||
|
import { beginPartyRuntimeRejoin } from "./partyRuntimeRegistry";
|
||||||
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||||
|
|
||||||
|
export function partyResurrectionBlockedByBoss(mobs: Readonly<Record<string, MobCombatState>>): boolean {
|
||||||
|
return Object.values(mobs).some((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normal-dungeon recovery only; never bypass Manastorm charges or online authority. */
|
||||||
|
export function resurrectDeadPartyMembers(checkpointId = "entrance", now = Date.now()): number {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
if (game.gameMode !== "dungeon" || !Number.isFinite(now) || !onlineSessionOwnsWorldSimulation()
|
||||||
|
|| partyResurrectionBlockedByBoss(combat.mobs)) return 0;
|
||||||
|
const checkpoint = resolveResurrectionCheckpoint(checkpointId);
|
||||||
|
if (!checkpoint) return 0;
|
||||||
|
const party = usePartyStore.getState();
|
||||||
|
const fallen = party.members.filter((member) => (combat.actors[member.id]?.health ?? member.health) <= 0);
|
||||||
|
if (!fallen.length) return 0;
|
||||||
|
const ids = new Set(fallen.map((member) => member.id));
|
||||||
|
const slots = derivePartySpawnSlots(checkpoint.spawn, party.members.length + 1);
|
||||||
|
useCombatStore.setState({ effects: combat.effects.filter((effect) => !effect.targetId || !ids.has(effect.targetId)) });
|
||||||
|
let restoredCount = 0;
|
||||||
|
for (const member of fallen) {
|
||||||
|
if (useCombatStore.getState().reviveActor(member.id, 1, PLAYER_AGGRO_ID, "party-checkpoint-resurrection", now) <= 0) continue;
|
||||||
|
const index = party.members.findIndex((candidate) => candidate.id === member.id);
|
||||||
|
beginPartyRuntimeRejoin(member.id, (slots[index + 1] ?? slots[0]).footPosition);
|
||||||
|
usePartyStore.getState().updateMemberCombat(member.id, {
|
||||||
|
status: "advancing", activeCast: null, activeCastTargetId: null, controlledUntil: 0, controlMechanic: null,
|
||||||
|
});
|
||||||
|
restoredCount += 1;
|
||||||
|
}
|
||||||
|
return restoredCount;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
registerPartyRuntimePosition,
|
registerPartyRuntimePosition,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimeTargetVisibility,
|
updatePartyRuntimeTargetVisibility,
|
||||||
|
beginPartyRuntimeRejoin,
|
||||||
|
finishPartyRuntimeRejoin,
|
||||||
} from "./partyRuntimeRegistry";
|
} from "./partyRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
activePartyTargetId,
|
activePartyTargetId,
|
||||||
@@ -47,6 +49,36 @@ describe("party combat runtime", () => {
|
|||||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||||
|
registerEngagedMob();
|
||||||
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||||
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]);
|
||||||
|
const beforeHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id))
|
||||||
|
.toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null });
|
||||||
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined();
|
||||||
|
expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id);
|
||||||
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth);
|
||||||
|
finishPartyRuntimeRejoin(tank.id);
|
||||||
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
||||||
|
advanceDungeonPartyCombat(2_000);
|
||||||
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => {
|
||||||
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||||
|
usePartyStore.getState().setCommand("stop");
|
||||||
|
beginPartyRuntimeRejoin(tank.id, [0, 0, 0]);
|
||||||
|
advanceDungeonPartyCombat(1_000);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing");
|
||||||
|
finishPartyRuntimeRejoin(tank.id);
|
||||||
|
advanceDungeonPartyCombat(2_000);
|
||||||
|
expect(usePartyStore.getState().command).toBe("stop");
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding");
|
||||||
|
});
|
||||||
|
|
||||||
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
||||||
registerEngagedMob();
|
registerEngagedMob();
|
||||||
for (const member of usePartyStore.getState().members) {
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { requireDungeonDefinition } from "./dungeonRegistry";
|
|||||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
getPartyRuntimePosition,
|
getPartyRuntimePosition,
|
||||||
|
partyRuntimeIsRejoining,
|
||||||
partyRuntimeCenter,
|
partyRuntimeCenter,
|
||||||
partyRuntimePositionAlongAttackCorridor,
|
partyRuntimePositionAlongAttackCorridor,
|
||||||
partyRuntimeTargetVisible,
|
partyRuntimeTargetVisible,
|
||||||
@@ -1058,7 +1059,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
));
|
));
|
||||||
let members: readonly PartyMember[] = authoritativeMembers;
|
let members: readonly PartyMember[] = authoritativeMembers;
|
||||||
const partyOrigin = partyRuntimeCenter(
|
const partyOrigin = partyRuntimeCenter(
|
||||||
members.map((member) => member.id),
|
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
|
||||||
combat.playerPosition,
|
combat.playerPosition,
|
||||||
);
|
);
|
||||||
let targetId = partyCommandTargetId(
|
let targetId = partyCommandTargetId(
|
||||||
@@ -1122,6 +1123,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (partyRuntimeIsRejoining(member.id)) {
|
||||||
|
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (member.controlledUntil > now) {
|
if (member.controlledUntil > now) {
|
||||||
nextMembers[index] = member.status === "holding"
|
nextMembers[index] = member.status === "holding"
|
||||||
? member
|
? member
|
||||||
|
|||||||
@@ -11,11 +11,41 @@ import {
|
|||||||
updatePartyRuntimeTargetVisibility,
|
updatePartyRuntimeTargetVisibility,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimePosition,
|
updatePartyRuntimePosition,
|
||||||
|
beginPartyRuntimeRejoin,
|
||||||
|
consumePartyRuntimeRejoin,
|
||||||
|
partyRuntimeIsRejoining,
|
||||||
|
finishPartyRuntimeRejoin,
|
||||||
} from "./partyRuntimeRegistry";
|
} from "./partyRuntimeRegistry";
|
||||||
|
|
||||||
describe("party runtime position registry", () => {
|
describe("party runtime position registry", () => {
|
||||||
beforeEach(clearPartyRuntimeRegistry);
|
beforeEach(clearPartyRuntimeRegistry);
|
||||||
|
|
||||||
|
it("consumes a copied relocation once and keeps the individual return task until completed", () => {
|
||||||
|
const origin: [number, number, number] = [1, 2, 3];
|
||||||
|
beginPartyRuntimeRejoin("tank", origin);
|
||||||
|
origin[0] = 999;
|
||||||
|
const result = consumePartyRuntimeRejoin("tank")! as [number, number, number];
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
result[0] = 888;
|
||||||
|
expect(getPartyRuntimePosition("tank")).toEqual([1, 2, 3]);
|
||||||
|
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(true);
|
||||||
|
expect(partyRuntimeIsRejoining("healer")).toBe(false);
|
||||||
|
finishPartyRuntimeRejoin("tank");
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pending resurrection tasks on actor unmount and dungeon switch", () => {
|
||||||
|
const unregister = registerPartyRuntimePosition("tank", [0, 0, 0]);
|
||||||
|
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||||
|
unregister();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
beginPartyRuntimeRejoin("tank", [1, 0, 0]);
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
expect(consumePartyRuntimeRejoin("tank")).toBeNull();
|
||||||
|
expect(partyRuntimeIsRejoining("tank")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks world actors without exposing mutable registry storage", () => {
|
it("tracks world actors without exposing mutable registry storage", () => {
|
||||||
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
|
||||||
updatePartyRuntimePosition("tank", 4, 5, 6);
|
updatePartyRuntimePosition("tank", 4, 5, 6);
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
|
|||||||
|
|
||||||
const positions = new Map<string, MutablePartyWorldPosition>();
|
const positions = new Map<string, MutablePartyWorldPosition>();
|
||||||
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
|
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
|
||||||
|
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
|
||||||
|
|
||||||
|
/** A one-shot scene relocation followed by an individual return-to-player task. */
|
||||||
|
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
|
||||||
|
rejoins.set(id, { position: [...position], pending: true });
|
||||||
|
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||||
|
targetVisibility.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
|
||||||
|
const rejoin = rejoins.get(id);
|
||||||
|
if (!rejoin?.pending) return null;
|
||||||
|
rejoin.pending = false;
|
||||||
|
return [...rejoin.position];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyRuntimeIsRejoining(id: string): boolean {
|
||||||
|
return rejoins.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishPartyRuntimeRejoin(id: string): void {
|
||||||
|
rejoins.delete(id);
|
||||||
|
}
|
||||||
let attackCorridor: {
|
let attackCorridor: {
|
||||||
readonly origin: MutablePartyWorldPosition;
|
readonly origin: MutablePartyWorldPosition;
|
||||||
readonly waypoints: readonly PartyWorldPosition[];
|
readonly waypoints: readonly PartyWorldPosition[];
|
||||||
@@ -21,6 +44,7 @@ export function registerPartyRuntimePosition(
|
|||||||
return () => {
|
return () => {
|
||||||
positions.delete(id);
|
positions.delete(id);
|
||||||
targetVisibility.delete(id);
|
targetVisibility.delete(id);
|
||||||
|
rejoins.delete(id);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +135,7 @@ export function updatePartyRuntimePosition(
|
|||||||
export function removePartyRuntimePosition(id: string): void {
|
export function removePartyRuntimePosition(id: string): void {
|
||||||
positions.delete(id);
|
positions.delete(id);
|
||||||
targetVisibility.delete(id);
|
targetVisibility.delete(id);
|
||||||
|
rejoins.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||||
@@ -166,5 +191,6 @@ export function partyRuntimeCenter(
|
|||||||
export function clearPartyRuntimeRegistry(): void {
|
export function clearPartyRuntimeRegistry(): void {
|
||||||
positions.clear();
|
positions.clear();
|
||||||
targetVisibility.clear();
|
targetVisibility.clear();
|
||||||
|
rejoins.clear();
|
||||||
attackCorridor = null;
|
attackCorridor = null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||||
|
import { availableResurrectionCheckpoints, dungeonResurrectionCheckpoints } from "./resurrectionCheckpoints";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
|
||||||
|
describe("boss resurrection checkpoints", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||||
|
useCombatStore.setState({ mobs: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always offers the entrance and unlocks every killed boss, including optional bosses", () => {
|
||||||
|
expect(dungeonResurrectionCheckpoints(dungeon, {})).toHaveLength(1);
|
||||||
|
const result = dungeonResurrectionCheckpoints(dungeon, {}, dungeon.bosses.map((boss) => boss.id));
|
||||||
|
expect(result.map((checkpoint) => checkpoint.id)).toEqual(["entrance", ...dungeon.bosses.map((boss) => `boss:${boss.id}`)]);
|
||||||
|
dungeon.bosses.forEach((boss, index) => expect(result[index + 1].spawn.footPosition).toEqual(boss.position));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses durable encounter completion after hydration when no corpse is registered", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: boss.id, now: 100 });
|
||||||
|
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 100, facts: { deadEntityIds: [boss.id], registeredEntityIds: [boss.id] } });
|
||||||
|
expect(activeDungeonCompletedObjectiveIds(dungeon.id)).toContain(boss.id);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
resetDungeonEncounters("boss-wipe", dungeon.id);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not award a checkpoint for an alive or script-despawned boss", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 100, boss: true });
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
const mob = useCombatStore.getState().mobs[boss.id];
|
||||||
|
useCombatStore.setState({ mobs: { [boss.id]: { ...mob, dead: true, health: 0, despawned: true } } });
|
||||||
|
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the defeated player and party reset signal to the selected boss, preserving the kill", () => {
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||||
|
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
const before = useGameStore.getState().resetRevision;
|
||||||
|
expect(respawnDefeatedPlayer(`boss:${boss.id}`)).toBe(true);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(boss.position);
|
||||||
|
expect(useGameStore.getState().resetRevision).toBe(before + 1);
|
||||||
|
expect(useCombatStore.getState().playerPosition).toEqual(boss.position);
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||||
|
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a defeated player explicitly choose the entrance instead of a previously saved boss spawn", () => {
|
||||||
|
useGameStore.setState({ activeSpawn: { footPosition: dungeon.bosses[0].position, yaw: 1 } });
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(respawnDefeatedPlayer("entrance")).toBe(true);
|
||||||
|
expect(useGameStore.getState().playerPosition).toEqual(dungeon.entrance.footPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects locked boss choices and cannot teleport a living player", () => {
|
||||||
|
const before = useGameStore.getState();
|
||||||
|
expect(respawnDefeatedPlayer("entrance")).toBe(false);
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useGameStore.getState()).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||||
|
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import type { DungeonDefinition, WorldSpawn } from "./dungeonTypes";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
|
||||||
|
export interface ResurrectionCheckpoint {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly spawn: WorldSpawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authored boss positions only: never accept a corpse position or arbitrary UI coordinates. */
|
||||||
|
export function dungeonResurrectionCheckpoints(
|
||||||
|
dungeon: DungeonDefinition,
|
||||||
|
mobs: Readonly<Record<string, MobCombatState>>,
|
||||||
|
completedObjectiveIds: readonly string[] = [],
|
||||||
|
): ResurrectionCheckpoint[] {
|
||||||
|
const completed = new Set(completedObjectiveIds);
|
||||||
|
return [
|
||||||
|
{ id: "entrance", name: "Dungeon entrance", spawn: dungeon.entrance },
|
||||||
|
...dungeon.bosses.filter((boss) => completed.has(boss.id)
|
||||||
|
|| (mobs[boss.id]?.dead && !mobs[boss.id]?.despawned)).map((boss) => ({
|
||||||
|
id: `boss:${boss.id}`, name: boss.name,
|
||||||
|
spawn: { footPosition: boss.position, yaw: dungeon.entrance.yaw },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableResurrectionCheckpoints(): ResurrectionCheckpoint[] {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
return dungeonResurrectionCheckpoints(requireDungeonDefinition(game.activeDungeonId),
|
||||||
|
useCombatStore.getState().mobs, activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveResurrectionCheckpoint(id: string): ResurrectionCheckpoint | null {
|
||||||
|
if (useGameStore.getState().gameMode !== "dungeon") return null;
|
||||||
|
return availableResurrectionCheckpoints().find((checkpoint) => checkpoint.id === id) ?? null;
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from "./partyNavigation";
|
} from "./partyNavigation";
|
||||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||||
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||||
import { planPartyRoute } from "./partyPathing";
|
import { emptyPartyRouteState, planPartyRoute } from "./partyPathing";
|
||||||
|
import { partyRejoinMovement } from "./partyRejoin";
|
||||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||||
@@ -111,6 +112,42 @@ function distance(left: readonly number[], right: readonly number[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Stormwind Stockade party navigation", () => {
|
describe("Stormwind Stockade party navigation", () => {
|
||||||
|
it("returns resurrected teammates from the entrance and every boss checkpoint through approved shipping corridors", () => {
|
||||||
|
const world = collisionWorld(true, true);
|
||||||
|
try {
|
||||||
|
const directSegmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
|
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||||
|
const checkpoints = [stockades.entrance, ...stockades.bosses.map((boss) => ({ footPosition: boss.position, yaw: stockades.entrance.yaw }))];
|
||||||
|
for (const checkpoint of checkpoints) {
|
||||||
|
const player = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||||
|
for (const slot of derivePartySpawnSlots(checkpoint, 5).slice(1)) {
|
||||||
|
const origin = findNearestPartyNavigationSurface(graph, slot.footPosition, 6)?.position;
|
||||||
|
expect(origin, JSON.stringify(slot)).toBeDefined();
|
||||||
|
let result = partyRejoinMovement("returning-member", origin!, player, 0, graph, emptyPartyRouteState(), {
|
||||||
|
directSegmentAllowed, routeSegmentAllowed,
|
||||||
|
});
|
||||||
|
expect(result.blocked, JSON.stringify({ checkpoint, origin, waypoints: result.route.waypoints })).toBe(false);
|
||||||
|
expect(result.rejoined || result.waypoint !== null).toBe(true);
|
||||||
|
for (let index = 1; index < result.route.waypoints.length; index += 1) {
|
||||||
|
expect(routeSegmentAllowed(result.route.waypoints[index - 1], result.route.waypoints[index])).toBe(true);
|
||||||
|
}
|
||||||
|
const current: [number, number, number] = [...origin!];
|
||||||
|
const next: [number, number, number] = [0, 0, 0];
|
||||||
|
for (let frame = 1; frame <= 4_000 && !result.rejoined; frame += 1) {
|
||||||
|
if (result.waypoint) {
|
||||||
|
stepPartyPosition(current, result.waypoint, .175, next);
|
||||||
|
current[0] = next[0]; current[1] = next[1]; current[2] = next[2];
|
||||||
|
}
|
||||||
|
result = partyRejoinMovement("returning-member", current, player, frame * 50, graph, result.route, {
|
||||||
|
directSegmentAllowed, routeSegmentAllowed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(result.rejoined, JSON.stringify({ checkpoint, origin, current, result })).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { world.free(); }
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||||
const world = collisionWorld(true, true);
|
const world = collisionWorld(true, true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
|
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
|
||||||
} from "./uiLayout";
|
} from "./uiLayout";
|
||||||
|
|
||||||
describe("personal UI layouts", () => {
|
describe("personal UI layouts", () => {
|
||||||
|
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
|
||||||
|
const ui = normalizeUiPreferences(null);
|
||||||
|
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
|
||||||
|
expect(resolveDpsMeterDisplay(ui.current.pc, false)).toBe("main");
|
||||||
|
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("companion");
|
||||||
|
expect(resolveDpsMeterDisplay(ui.current.pc, true)).toBe("companion");
|
||||||
|
ui.current.mobile.dpsMeterDisplay = "main";
|
||||||
|
ui.current.mobile.elements.dps = { visible: false, position: { x: .6, y: .7 }, width: 300 };
|
||||||
|
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||||
|
expect(resolveDpsMeterDisplay(ui.current.mobile, true)).toBe("main");
|
||||||
|
});
|
||||||
|
it("imports older UI templates without a DPS meter", () => {
|
||||||
|
const exported = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
|
||||||
|
for (const device of ["pc", "mobile"]) {
|
||||||
|
delete exported.current[device].elements.dps;
|
||||||
|
delete exported.current[device].dpsMeterDisplay;
|
||||||
|
}
|
||||||
|
expect(importUiPreferences(JSON.stringify(exported)).current.pc.elements.dps.visible).toBe(true);
|
||||||
|
exported.current.pc.dpsMeterDisplay = "invalid";
|
||||||
|
expect(() => importUiPreferences(JSON.stringify(exported))).toThrow("DPS meter display");
|
||||||
|
});
|
||||||
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
|
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
|
||||||
const ui = normalizeUiPreferences(undefined);
|
const ui = normalizeUiPreferences(undefined);
|
||||||
expect(ui.slots.mobile).toEqual([null, null, null]);
|
expect(ui.slots.mobile).toEqual([null, null, null]);
|
||||||
|
|||||||
+13
-2
@@ -7,7 +7,7 @@ export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
|||||||
export const HUD_ELEMENTS = {
|
export const HUD_ELEMENTS = {
|
||||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||||
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
|
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||||||
@@ -25,6 +25,7 @@ export interface DesktopActionSlot { abilityId: string | null; key: string | nul
|
|||||||
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
||||||
export interface UiLayout {
|
export interface UiLayout {
|
||||||
elements: Record<HudElementId, HudElementLayout>;
|
elements: Record<HudElementId, HudElementLayout>;
|
||||||
|
dpsMeterDisplay: "auto" | "main" | "companion";
|
||||||
wowBars: boolean;
|
wowBars: boolean;
|
||||||
barCount: number;
|
barCount: number;
|
||||||
bars: DesktopActionBar[];
|
bars: DesktopActionBar[];
|
||||||
@@ -42,6 +43,7 @@ export function defaultUiLayout(): UiLayout {
|
|||||||
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
||||||
visible: true, position: null, width: null,
|
visible: true, position: null, width: null,
|
||||||
}])) as UiLayout["elements"],
|
}])) as UiLayout["elements"],
|
||||||
|
dpsMeterDisplay: "auto",
|
||||||
wowBars: false,
|
wowBars: false,
|
||||||
barCount: 1,
|
barCount: 1,
|
||||||
bars: Array.from({ length: 5 }, (_, bar) => ({
|
bars: Array.from({ length: 5 }, (_, bar) => ({
|
||||||
@@ -106,7 +108,13 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
return { ...defaults,
|
||||||
|
dpsMeterDisplay: source.dpsMeterDisplay === "main" || source.dpsMeterDisplay === "companion" ? source.dpsMeterDisplay : "auto",
|
||||||
|
wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||||||
|
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||||||
}
|
}
|
||||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||||
const source = record(value);
|
const source = record(value);
|
||||||
@@ -146,9 +154,12 @@ export function importUiPreferences(text: string): UiPreferences {
|
|||||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||||
const validateLayout = (value: unknown) => {
|
const validateLayout = (value: unknown) => {
|
||||||
const layout = record(value);
|
const layout = record(value);
|
||||||
|
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||||
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
||||||
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
||||||
for (const id of Object.keys(HUD_ELEMENTS)) {
|
for (const id of Object.keys(HUD_ELEMENTS)) {
|
||||||
|
// Version-one exports made before the DPS meter inherit its defaults.
|
||||||
|
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||||||
const element = record(record(layout.elements)[id]);
|
const element = record(record(layout.elements)[id]);
|
||||||
const position = record(element.position);
|
const position = record(element.position);
|
||||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useShellStore } from "../app/shellStore";
|
|||||||
import { emitControllerToken, setExternalControllerMovement } from "../input/controller";
|
import { emitControllerToken, setExternalControllerMovement } from "../input/controller";
|
||||||
import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync";
|
import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync";
|
||||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||||
|
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
|
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||||
|
|
||||||
export function connectAuthoritativeDualScreen(
|
export function connectAuthoritativeDualScreen(
|
||||||
channel: BroadcastChannel,
|
channel: BroadcastChannel,
|
||||||
@@ -30,6 +32,12 @@ export function connectAuthoritativeDualScreen(
|
|||||||
const unsubscribeParty = usePartyStore.subscribe(() => {
|
const unsubscribeParty = usePartyStore.subscribe(() => {
|
||||||
if (companionReady) gamePublisher.request();
|
if (companionReady) gamePublisher.request();
|
||||||
});
|
});
|
||||||
|
const unsubscribeUi = useUiLayoutStore.subscribe(() => {
|
||||||
|
if (companionReady) gamePublisher.request();
|
||||||
|
});
|
||||||
|
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||||
|
if (companionReady) gamePublisher.request();
|
||||||
|
});
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === "companion-ready") {
|
if (event.data.type === "companion-ready") {
|
||||||
companionReady = true;
|
companionReady = true;
|
||||||
@@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen(
|
|||||||
unsubscribeGame();
|
unsubscribeGame();
|
||||||
unsubscribeCombat();
|
unsubscribeCombat();
|
||||||
unsubscribeParty();
|
unsubscribeParty();
|
||||||
|
unsubscribeUi();
|
||||||
|
unsubscribeDps();
|
||||||
gamePublisher.dispose();
|
gamePublisher.dispose();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { connectAuthoritativeDualScreen } from "./authoritativeDualScreenRuntime";
|
||||||
|
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
|
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||||
|
import { emptyDpsMeter } from "../game/dpsMeter";
|
||||||
|
import type { DualScreenMessage } from "./dualScreenSync";
|
||||||
|
|
||||||
|
describe("dedicated companion DPS synchronization", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useUiLayoutStore.getState().finish(false);
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes meter ticks and layout drafts, then unsubscribes on disconnect", () => {
|
||||||
|
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||||
|
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { search: "?display=top" } });
|
||||||
|
const postMessage = vi.fn<(message: DualScreenMessage) => void>();
|
||||||
|
const channel = { postMessage, onmessage: null } as unknown as BroadcastChannel;
|
||||||
|
const disconnect = connectAuthoritativeDualScreen(channel, { data: { type: "companion-ready" } } as MessageEvent<DualScreenMessage>);
|
||||||
|
try {
|
||||||
|
useUiLayoutStore.getState().begin();
|
||||||
|
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, dpsMeterDisplay: "companion", elements: {
|
||||||
|
...layout.elements, dps: { visible: false, position: { x: .4, y: .6 }, width: 300 },
|
||||||
|
} }));
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
let latest = postMessage.mock.lastCall?.[0];
|
||||||
|
expect(latest?.type).toBe("app-state");
|
||||||
|
if (latest?.type !== "app-state") throw new Error("Missing companion snapshot");
|
||||||
|
expect(latest.snapshot.game.dpsMeter).toMatchObject({ display: "companion", layout: {
|
||||||
|
visible: false, position: { x: .4, y: .6 }, width: 300,
|
||||||
|
} });
|
||||||
|
|
||||||
|
useDpsMeterStore.setState({ startedAt: 1_000, endedAt: null, updatedAt: 3_000, elapsedMs: 2_000,
|
||||||
|
damage: { test: { id: "test", name: "Test", color: "#fff", total: 200 } } }, true);
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
latest = postMessage.mock.lastCall?.[0];
|
||||||
|
if (latest?.type !== "app-state") throw new Error("Missing DPS snapshot");
|
||||||
|
expect(latest.snapshot.game.dpsMeter.values).toMatchObject({ totalDamage: 200, totalDps: 100, durationSeconds: 2 });
|
||||||
|
} finally { disconnect(); }
|
||||||
|
postMessage.mockClear();
|
||||||
|
useUiLayoutStore.getState().finish(false);
|
||||||
|
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,12 @@ import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../
|
|||||||
import type { DungeonId } from "../game/dungeonCatalog";
|
import type { DungeonId } from "../game/dungeonCatalog";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||||
|
import { currentDpsMeterSnapshot } from "../game/dpsMeterStore";
|
||||||
|
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||||
|
import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
|
import { normalizeHudPosition } from "../game/hudLayout";
|
||||||
|
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
|
||||||
|
import { currentDisplayLayout } from "./displayLayout";
|
||||||
|
|
||||||
export { createDualScreenChannel } from "./dualScreenChannel";
|
export { createDualScreenChannel } from "./dualScreenChannel";
|
||||||
|
|
||||||
@@ -89,6 +95,12 @@ export interface CompanionPartyObjectiveSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanionGameSnapshot {
|
export interface CompanionGameSnapshot {
|
||||||
|
dpsMeter: {
|
||||||
|
values: DpsMeterSnapshot;
|
||||||
|
layout: HudElementLayout;
|
||||||
|
display: "main" | "companion";
|
||||||
|
editing: boolean;
|
||||||
|
};
|
||||||
gameMode: GameMode;
|
gameMode: GameMode;
|
||||||
dungeonId: string;
|
dungeonId: string;
|
||||||
dungeonTitle: string;
|
dungeonTitle: string;
|
||||||
@@ -159,7 +171,8 @@ export type CompanionCommand =
|
|||||||
| { name: "cyclePartyMember"; direction: 1 | -1 }
|
| { name: "cyclePartyMember"; direction: 1 | -1 }
|
||||||
| { name: "setPartyCommand"; command: PartyCommand }
|
| { name: "setPartyCommand"; command: PartyCommand }
|
||||||
| { name: "recallParty" }
|
| { name: "recallParty" }
|
||||||
| { name: "resetAtEntrance" };
|
| { name: "resetAtEntrance" }
|
||||||
|
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||||
|
|
||||||
export type DualScreenMessage =
|
export type DualScreenMessage =
|
||||||
| { type: "companion-ready" }
|
| { type: "companion-ready" }
|
||||||
@@ -180,6 +193,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
|||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
const manastorm = useManastormStore.getState();
|
const manastorm = useManastormStore.getState();
|
||||||
|
const uiLayout = activeUiLayout();
|
||||||
const dungeon = requireDungeonDefinition(game.activeDungeonId);
|
const dungeon = requireDungeonDefinition(game.activeDungeonId);
|
||||||
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
|
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
|
||||||
title: dungeon.title,
|
title: dungeon.title,
|
||||||
@@ -251,6 +265,12 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
|||||||
draft: shell.draft,
|
draft: shell.draft,
|
||||||
notice: shell.notice,
|
notice: shell.notice,
|
||||||
game: {
|
game: {
|
||||||
|
dpsMeter: {
|
||||||
|
values: currentDpsMeterSnapshot(),
|
||||||
|
layout: uiLayout.elements.dps,
|
||||||
|
display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"),
|
||||||
|
editing: game.overlay === "ui-layout",
|
||||||
|
},
|
||||||
gameMode: game.gameMode,
|
gameMode: game.gameMode,
|
||||||
dungeonId: dungeon.id,
|
dungeonId: dungeon.id,
|
||||||
dungeonTitle: stagePresentation.title,
|
dungeonTitle: stagePresentation.title,
|
||||||
@@ -322,6 +342,17 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
|||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
switch (command.name) {
|
switch (command.name) {
|
||||||
|
case "editDpsMeter": {
|
||||||
|
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
|
||||||
|
const update: Partial<HudElementLayout> = {};
|
||||||
|
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
|
||||||
|
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width)));
|
||||||
|
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: {
|
||||||
|
...layout.elements, dps: { ...layout.elements.dps, ...update },
|
||||||
|
} }));
|
||||||
|
useUiLayoutStore.setState({ selectedElement: "dps" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "selectCharacter": shell.selectCharacter(command.characterId); break;
|
case "selectCharacter": shell.selectCharacter(command.characterId); break;
|
||||||
case "openCreator": shell.openCreator(); break;
|
case "openCreator": shell.openCreator(); break;
|
||||||
case "cancelCreator": shell.cancelCreator(); break;
|
case "cancelCreator": shell.cancelCreator(); break;
|
||||||
|
|||||||
@@ -72,10 +72,14 @@ import {
|
|||||||
type PartyRouteState,
|
type PartyRouteState,
|
||||||
} from "../game/partyPathing";
|
} from "../game/partyPathing";
|
||||||
import { partyCombatRangeBand } from "../game/partyRuntime";
|
import { partyCombatRangeBand } from "../game/partyRuntime";
|
||||||
|
import { partyRejoinMovement } from "../game/partyRejoin";
|
||||||
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
||||||
import {
|
import {
|
||||||
clearPartyRuntimeAttackCorridor,
|
clearPartyRuntimeAttackCorridor,
|
||||||
|
consumePartyRuntimeRejoin,
|
||||||
|
finishPartyRuntimeRejoin,
|
||||||
getPartyRuntimePosition,
|
getPartyRuntimePosition,
|
||||||
|
partyRuntimeIsRejoining,
|
||||||
registerPartyRuntimePosition,
|
registerPartyRuntimePosition,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimePosition,
|
updatePartyRuntimePosition,
|
||||||
@@ -419,6 +423,7 @@ function PartyActors({
|
|||||||
: null;
|
: null;
|
||||||
const recallPosition = navigationRecall?.position ?? player;
|
const recallPosition = navigationRecall?.position ?? player;
|
||||||
party.members.forEach((member, index) => {
|
party.members.forEach((member, index) => {
|
||||||
|
finishPartyRuntimeRejoin(member.id);
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
// Attach a recalled squad to the reviewed surface just as initial
|
// Attach a recalled squad to the reviewed surface just as initial
|
||||||
// spawns do. An exact visual-floor point can sit between Recast stair
|
// spawns do. An exact visual-floor point can sit between Recast stair
|
||||||
@@ -518,6 +523,7 @@ function PartyActors({
|
|||||||
members.forEach((member, index) => {
|
members.forEach((member, index) => {
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
|
||||||
|
finishPartyRuntimeRejoin(member.id);
|
||||||
position[0] = slot.footPosition[0];
|
position[0] = slot.footPosition[0];
|
||||||
position[1] = slot.footPosition[1];
|
position[1] = slot.footPosition[1];
|
||||||
position[2] = slot.footPosition[2];
|
position[2] = slot.footPosition[2];
|
||||||
@@ -605,6 +611,29 @@ function PartyActors({
|
|||||||
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||||
const routeNow = clock.elapsedTime * 1_000;
|
const routeNow = clock.elapsedTime * 1_000;
|
||||||
const combatNow = Date.now();
|
const combatNow = Date.now();
|
||||||
|
for (const member of party.members) {
|
||||||
|
if (member.health <= 0) {
|
||||||
|
finishPartyRuntimeRejoin(member.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const checkpoint = consumePartyRuntimeRejoin(member.id);
|
||||||
|
if (!checkpoint) continue;
|
||||||
|
const landing = navigationGraph
|
||||||
|
? findNearestPartyNavigationSurface(navigationGraph, checkpoint, 6)?.position ?? checkpoint : checkpoint;
|
||||||
|
const current = positionsRef.current.get(member.id) ?? [...landing];
|
||||||
|
current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2];
|
||||||
|
positionsRef.current.set(member.id, current);
|
||||||
|
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
||||||
|
combatRoutesRef.current.delete(member.id);
|
||||||
|
combatAnchorsRef.current.delete(member.id);
|
||||||
|
followerCursorsRef.current.delete(member.id);
|
||||||
|
directFollowMovingRef.current.delete(member.id);
|
||||||
|
stuckProgressRef.current.delete(member.id);
|
||||||
|
updatePartyRuntimeTargetVisibility(member.id, null, false);
|
||||||
|
const animationSignal = animationEventSignalsRef.current.get(member.id);
|
||||||
|
animationRevisionRef.current += 1;
|
||||||
|
if (animationSignal) animationSignal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
||||||
|
}
|
||||||
const playerTrail = playerTrailRef.current.points;
|
const playerTrail = playerTrailRef.current.points;
|
||||||
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
||||||
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
||||||
@@ -639,7 +668,7 @@ function PartyActors({
|
|||||||
lastLeaderWaypointRef.current = null;
|
lastLeaderWaypointRef.current = null;
|
||||||
}
|
}
|
||||||
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
|
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
|
||||||
const leaderIndex = party.members.findIndex((member) => member.health > 0);
|
const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id));
|
||||||
const safeDelta = Math.min(delta, 0.05);
|
const safeDelta = Math.min(delta, 0.05);
|
||||||
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||||
@@ -994,7 +1023,8 @@ function PartyActors({
|
|||||||
}
|
}
|
||||||
lastHealthRef.current.set(member.id, member.health);
|
lastHealthRef.current.set(member.id, member.health);
|
||||||
if (!actor || !current) continue;
|
if (!actor || !current) continue;
|
||||||
const memberTargetId = party.memberTargetIds[member.id] ?? party.activeMobId;
|
const rejoining = partyRuntimeIsRejoining(member.id);
|
||||||
|
const memberTargetId = rejoining ? null : party.memberTargetIds[member.id] ?? party.activeMobId;
|
||||||
const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
|
const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
|
||||||
const canSeeActiveTarget = Boolean(
|
const canSeeActiveTarget = Boolean(
|
||||||
memberTargetId
|
memberTargetId
|
||||||
@@ -1012,10 +1042,28 @@ function PartyActors({
|
|||||||
if (member.health <= 0) {
|
if (member.health <= 0) {
|
||||||
const signal = movingSignalsRef.current.get(member.id);
|
const signal = movingSignalsRef.current.get(member.id);
|
||||||
if (signal) signal.current = false;
|
if (signal) signal.current = false;
|
||||||
} else if (party.command === "stop") {
|
|
||||||
// Holding is represented by retaining the current world-space tuple.
|
|
||||||
} else if (member.activeCast || member.controlledUntil > combatNow) {
|
} else if (member.activeCast || member.controlledUntil > combatNow) {
|
||||||
// Casting and crowd control are intentional immobility, not stuck navigation.
|
// Casting and crowd control are intentional immobility, not stuck navigation.
|
||||||
|
} else if (rejoining) {
|
||||||
|
const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow,
|
||||||
|
navigationGraph, combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(),
|
||||||
|
{ directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed });
|
||||||
|
combatRoutesRef.current.set(member.id, intent.route);
|
||||||
|
if (intent.rejoined) {
|
||||||
|
finishPartyRuntimeRejoin(member.id);
|
||||||
|
combatRoutesRef.current.delete(member.id);
|
||||||
|
followerCursorsRef.current.delete(member.id);
|
||||||
|
} else {
|
||||||
|
movementExpected = true;
|
||||||
|
frameRouteBlocked ||= intent.blocked;
|
||||||
|
if (intent.waypoint) {
|
||||||
|
desired[0] = intent.waypoint[0]; desired[1] = intent.waypoint[1]; desired[2] = intent.waypoint[2];
|
||||||
|
hasDesired = true;
|
||||||
|
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (party.command === "stop") {
|
||||||
|
// Holding is represented by retaining the current world-space tuple.
|
||||||
} else if (index === leaderIndex) {
|
} else if (index === leaderIndex) {
|
||||||
hasDesired = leaderHasWaypoint;
|
hasDesired = leaderHasWaypoint;
|
||||||
movementExpected = leaderMovementExpected;
|
movementExpected = leaderMovementExpected;
|
||||||
@@ -1254,7 +1302,7 @@ function PartyActors({
|
|||||||
const movementProfile = movementProfileFor(member.id, index);
|
const movementProfile = movementProfileFor(member.id, index);
|
||||||
|
|
||||||
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
||||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) {
|
||||||
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
||||||
* movementProfile.speedMultiplier;
|
* movementProfile.speedMultiplier;
|
||||||
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
||||||
@@ -1287,7 +1335,7 @@ function PartyActors({
|
|||||||
stuckProgress = createPartyStuckRecoveryState(current, routeNow);
|
stuckProgress = createPartyStuckRecoveryState(current, routeNow);
|
||||||
stuckProgressRef.current.set(member.id, stuckProgress);
|
stuckProgressRef.current.set(member.id, stuckProgress);
|
||||||
}
|
}
|
||||||
const recoveryGoal = memberTargetPosition ? `mob:${memberTargetId}`
|
const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}`
|
||||||
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
|
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
|
||||||
const recovery = updatePartyStuckRecovery(stuckProgress, {
|
const recovery = updatePartyStuckRecovery(stuckProgress, {
|
||||||
position: current,
|
position: current,
|
||||||
@@ -1296,7 +1344,7 @@ function PartyActors({
|
|||||||
movementExpected,
|
movementExpected,
|
||||||
health: member.health,
|
health: member.health,
|
||||||
paused: !active || game.paused || game.mapOpen || game.companionOpen,
|
paused: !active || game.paused || game.mapOpen || game.companionOpen,
|
||||||
stopped: party.command === "stop",
|
stopped: party.command === "stop" && !rejoining,
|
||||||
casting: member.activeCast !== null,
|
casting: member.activeCast !== null,
|
||||||
controlled: member.controlledUntil > combatNow,
|
controlled: member.controlledUntil > combatNow,
|
||||||
nowMs: routeNow,
|
nowMs: routeNow,
|
||||||
@@ -1316,7 +1364,7 @@ function PartyActors({
|
|||||||
const landing = choosePartyStuckRecoveryPosition({
|
const landing = choosePartyStuckRecoveryPosition({
|
||||||
current,
|
current,
|
||||||
player: playerNavigation,
|
player: playerNavigation,
|
||||||
groupAnchor: index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
|
groupAnchor: rejoining || index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
|
||||||
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
|
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
|
||||||
validate: recoveryPointAllowed,
|
validate: recoveryPointAllowed,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -731,6 +731,16 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.party-frame.is-selected { border-color: rgba(229, 207, 110, .75); background: linear-gradient(100deg, rgba(39, 43, 20, .96), rgba(18, 38, 24, .88)); box-shadow: 0 0 0 1px rgba(229,207,110,.14), 0 6px 18px rgba(0,0,0,.32); }
|
.party-frame.is-selected { border-color: rgba(229, 207, 110, .75); background: linear-gradient(100deg, rgba(39, 43, 20, .96), rgba(18, 38, 24, .88)); box-shadow: 0 0 0 1px rgba(229,207,110,.14), 0 6px 18px rgba(0,0,0,.32); }
|
||||||
.party-frame.is-dispellable { border-color: var(--dispel-bright); box-shadow: 0 0 0 1px rgba(var(--dispel-rgb),.72), 0 0 12px rgba(var(--dispel-rgb),.62), 0 6px 18px rgba(0,0,0,.32); }
|
.party-frame.is-dispellable { border-color: var(--dispel-bright); box-shadow: 0 0 0 1px rgba(var(--dispel-rgb),.72), 0 0 12px rgba(var(--dispel-rgb),.62), 0 6px 18px rgba(0,0,0,.32); }
|
||||||
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
||||||
|
.party-rez-button { display: grid; width: 100%; min-height: 44px; gap: 3px; padding: 6px 8px; border: 1px solid var(--party-rez-border, rgba(224,197,101,.5)); border-radius: 4px; background: var(--party-rez-background, linear-gradient(110deg, rgba(41,45,23,.96), rgba(13,28,20,.94))); color: var(--party-rez-color, #fff0b0); cursor: pointer; }
|
||||||
|
.party-rez-button strong { font-size: 10px; }
|
||||||
|
.party-rez-button small { color: #b6c4a8; font-size: 8px; }
|
||||||
|
.party-rez-button:hover:not(:disabled), .party-rez-button:focus-visible { border-color: #fff0b0; outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||||
|
.party-rez-button:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.party-resurrection { display: grid; gap: 4px; }
|
||||||
|
.resurrection-destination { display: grid; width: 100%; gap: 3px; margin-bottom: 6px; color: #b6c4a8; font-size: 10px; text-align: left; }
|
||||||
|
.resurrection-destination select { width: 100%; min-width: 0; min-height: 44px; padding: 4px 7px; border: 1px solid rgba(224,197,101,.5); border-radius: 4px; background: #142219; color: #fff0b0; font: inherit; }
|
||||||
|
.resurrection-destination select:focus-visible { outline: 1px solid #fff0b0; outline-offset: 2px; }
|
||||||
|
.resurrection-destination select:disabled { opacity: .6; }
|
||||||
.party-frame--player { opacity: .9; }
|
.party-frame--player { opacity: .9; }
|
||||||
.party-frame--player.is-selected { opacity: 1; }
|
.party-frame--player.is-selected { opacity: 1; }
|
||||||
.party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
|
.party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar";
|
|||||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||||
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
|
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
|
||||||
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||||
|
import { EditableHudElement } from "./EditableHudElement";
|
||||||
|
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||||
|
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
|
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||||
|
|
||||||
function safePercent(current: number, maximum: number): number {
|
function safePercent(current: number, maximum: number): number {
|
||||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||||
@@ -456,14 +460,21 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section className="companion-map-card">
|
<div className="companion-tactical__overview">
|
||||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
<section className="companion-map-card">
|
||||||
<div>
|
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
<div>
|
||||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||||
</div>
|
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
{snapshot.game.dpsMeter.display === "companion" && <EditableHudElement id="dps" hostSelector=".companion-panel"
|
||||||
|
elementLayout={snapshot.game.dpsMeter.layout} editing={snapshot.game.dpsMeter.editing}
|
||||||
|
onChange={(update) => send({ name: "editDpsMeter", update })}>
|
||||||
|
<DpsMeterWidget compact snapshot={snapshot.game.dpsMeter.values} width={snapshot.game.dpsMeter.layout.width} />
|
||||||
|
</EditableHudElement>}
|
||||||
|
</div>
|
||||||
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
|
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
items={actionItems}
|
items={actionItems}
|
||||||
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
|
|||||||
useGameStore();
|
useGameStore();
|
||||||
useCombatStore();
|
useCombatStore();
|
||||||
usePartyStore();
|
usePartyStore();
|
||||||
|
useUiLayoutStore();
|
||||||
|
useDpsMeterStore();
|
||||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||||
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
|
import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||||
|
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||||
|
import { EditableHudElement } from "./EditableHudElement";
|
||||||
|
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||||
|
|
||||||
|
export function DpsMeter() {
|
||||||
|
useDpsMeterStore();
|
||||||
|
const ui = useUiLayoutStore();
|
||||||
|
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||||
|
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||||
|
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||||
|
import "./dpsMeter.css";
|
||||||
|
|
||||||
|
export function DpsMeterWidget({ snapshot, width, compact = false }: {
|
||||||
|
snapshot: DpsMeterSnapshot; width?: number | null; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const number = (value: number) => Math.round(value).toLocaleString();
|
||||||
|
return <section className={`dps-meter ${compact ? "dps-meter--compact" : ""}`} aria-label="DPS meter"
|
||||||
|
style={{ "--dps-meter-width": `${width ?? 230}px` } as CSSProperties}>
|
||||||
|
<header><strong>DPS</strong><small>{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"}</small>
|
||||||
|
<span>{Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}</span></header>
|
||||||
|
{snapshot.rows.length > 0 ? <ol>{snapshot.rows.map((row) => <li key={row.id}
|
||||||
|
title={`${row.name}: ${number(row.dps)} DPS · ${number(row.total)} damage`}
|
||||||
|
style={{ "--dps-color": row.color } as CSSProperties}>
|
||||||
|
<i style={{ width: `${row.percent}%` }} aria-hidden="true" /><span>{row.name}</span><b>{number(row.dps)}</b>
|
||||||
|
</li>)}</ol> : <p>Damage dealt per second</p>}
|
||||||
|
{snapshot.rows.length > 0 && <footer><span>Party · {number(snapshot.totalDamage)} damage</span><b>{number(snapshot.totalDps)} DPS</b></footer>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import "./uiLayout.css";
|
||||||
|
|
||||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
|
||||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||||
@@ -14,14 +15,17 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, elementLayout,
|
||||||
|
editing: editingOverride, hostSelector = ".game-shell", onChange }: {
|
||||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||||
|
elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string;
|
||||||
|
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||||
}) {
|
}) {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
const editing = overlay === "ui-layout";
|
const editing = editingOverride ?? overlay === "ui-layout";
|
||||||
const state = useUiLayoutStore();
|
const state = useUiLayoutStore();
|
||||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||||
const element = layout.elements[id];
|
const element = elementLayout ?? layout.elements[id];
|
||||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [host, setHost] = useState<Element | null>(null);
|
const [host, setHost] = useState<Element | null>(null);
|
||||||
@@ -34,14 +38,14 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
|||||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||||
|
|
||||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (positioned || !ref.current) return;
|
if (positioned || !ref.current) return;
|
||||||
const node = ref.current;
|
const node = ref.current;
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const child = node.firstElementChild?.getBoundingClientRect();
|
const child = node.firstElementChild?.getBoundingClientRect();
|
||||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
const host = node.closest(hostSelector)?.getBoundingClientRect();
|
||||||
if (child && host && child.width > 0 && child.height > 0) {
|
if (child && host && child.width > 0 && child.height > 0) {
|
||||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||||
@@ -52,21 +56,23 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
|||||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||||
window.addEventListener("resize", measure);
|
window.addEventListener("resize", measure);
|
||||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||||
}, [positioned, children]);
|
}, [positioned, children, hostSelector]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const node = ref.current;
|
const node = ref.current;
|
||||||
const host = node?.closest(".game-shell");
|
const host = node?.closest(hostSelector);
|
||||||
if (!positioned || !node || !host) return;
|
if (!positioned || !node || !host) return;
|
||||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||||
const observer = new ResizeObserver(measure);
|
const observer = new ResizeObserver(measure);
|
||||||
observer.observe(node); observer.observe(host); measure();
|
observer.observe(node); observer.observe(host); measure();
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [positioned, host]);
|
}, [positioned, host, hostSelector]);
|
||||||
|
|
||||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
const updatePosition = (x: number, y: number) => {
|
||||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
|
||||||
} }));
|
if (onChange) onChange(update);
|
||||||
|
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
|
||||||
|
};
|
||||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||||
@@ -85,7 +91,7 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
|||||||
}}
|
}}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (drag.current?.pointerId !== event.pointerId) return;
|
if (drag.current?.pointerId !== event.pointerId) return;
|
||||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
const host = ref.current!.closest(hostSelector)!.getBoundingClientRect();
|
||||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||||
|
|||||||
+23
-8
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
|
|||||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||||
import { EncounterTimeline } from "./EncounterTimeline";
|
import { EncounterTimeline } from "./EncounterTimeline";
|
||||||
import { PartyFrames } from "./PartyFrames";
|
import { PartyFrames } from "./PartyFrames";
|
||||||
|
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||||
|
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||||
import { AuraStrip } from "./AuraStrip";
|
import { AuraStrip } from "./AuraStrip";
|
||||||
import { ShelvedHud } from "@shelvedHud";
|
import { ShelvedHud } from "@shelvedHud";
|
||||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||||
import { BossLootWindow } from "./BossLootWindow";
|
import { BossLootWindow } from "./BossLootWindow";
|
||||||
import { EditableHudElement } from "./EditableHudElement";
|
import { EditableHudElement } from "./EditableHudElement";
|
||||||
|
import { DpsMeter } from "./DpsMeter";
|
||||||
import { DesktopActionBars } from "./DesktopActionBars";
|
import { DesktopActionBars } from "./DesktopActionBars";
|
||||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
import { SummonFrames } from "./SummonFrames";
|
import { SummonFrames } from "./SummonFrames";
|
||||||
@@ -82,12 +85,16 @@ export function DefeatedPrompt({
|
|||||||
resurrectionCharges,
|
resurrectionCharges,
|
||||||
onReturn,
|
onReturn,
|
||||||
sharedOnline = false,
|
sharedOnline = false,
|
||||||
|
checkpoints = [],
|
||||||
}: {
|
}: {
|
||||||
gameMode: "dungeon" | "manastorm";
|
gameMode: "dungeon" | "manastorm";
|
||||||
resurrectionCharges: number;
|
resurrectionCharges: number;
|
||||||
onReturn: () => void;
|
onReturn: (checkpointId?: string) => void;
|
||||||
sharedOnline?: boolean;
|
sharedOnline?: boolean;
|
||||||
|
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||||
}) {
|
}) {
|
||||||
|
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||||
|
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||||
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -103,11 +110,16 @@ export function DefeatedPrompt({
|
|||||||
Stay down and wait for a party member to resurrect you, or choose to return now.
|
Stay down and wait for a party member to resurrect you, or choose to return now.
|
||||||
</p>
|
</p>
|
||||||
{canReturn ? (
|
{canReturn ? (
|
||||||
<button type="button" className="button button--primary" onClick={onReturn}>
|
<>
|
||||||
{gameMode === "manastorm"
|
{gameMode === "dungeon" && checkpoints.length > 0 ? (
|
||||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
|
||||||
: "Respawn at entrance"}
|
) : null}
|
||||||
</button>
|
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
|
||||||
|
{gameMode === "manastorm"
|
||||||
|
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||||
|
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : sharedOnline ? (
|
) : sharedOnline ? (
|
||||||
<small>Your shared run is still active. A living player can resurrect you.</small>
|
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||||
) : (
|
) : (
|
||||||
@@ -159,6 +171,7 @@ export function Hud() {
|
|||||||
const assetStatus = useGameStore((state) => state.assetStatus);
|
const assetStatus = useGameStore((state) => state.assetStatus);
|
||||||
const assetMessage = useGameStore((state) => state.assetMessage);
|
const assetMessage = useGameStore((state) => state.assetMessage);
|
||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
|
const resurrectionCheckpoints = useResurrectionCheckpoints();
|
||||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||||
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
||||||
@@ -349,12 +362,12 @@ export function Hud() {
|
|||||||
mapId: dungeon.mapId,
|
mapId: dungeon.mapId,
|
||||||
areaName,
|
areaName,
|
||||||
});
|
});
|
||||||
const returnFromDefeat = () => {
|
const returnFromDefeat = (checkpointId?: string) => {
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
resurrectManastormSession({ kind: "player" });
|
resurrectManastormSession({ kind: "player" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
respawnDefeatedPlayer();
|
respawnDefeatedPlayer(checkpointId ?? "entrance");
|
||||||
};
|
};
|
||||||
const finishDungeon = () => {
|
const finishDungeon = () => {
|
||||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||||
@@ -364,6 +377,7 @@ export function Hud() {
|
|||||||
return (
|
return (
|
||||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||||
<ShelvedHud />
|
<ShelvedHud />
|
||||||
|
<DpsMeter />
|
||||||
<BossLootWindow />
|
<BossLootWindow />
|
||||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||||
@@ -501,6 +515,7 @@ export function Hud() {
|
|||||||
gameMode={gameMode}
|
gameMode={gameMode}
|
||||||
resurrectionCharges={resurrectionCharges}
|
resurrectionCharges={resurrectionCharges}
|
||||||
onReturn={returnFromDefeat}
|
onReturn={returnFromDefeat}
|
||||||
|
checkpoints={resurrectionCheckpoints}
|
||||||
sharedOnline={onlineSession !== null}
|
sharedOnline={onlineSession !== null}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+29
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { CSSProperties } from "react";
|
import { useState, type CSSProperties } from "react";
|
||||||
import { classById, raceById } from "../app/characterCatalog";
|
import { classById, raceById } from "../app/characterCatalog";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
|||||||
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
|
||||||
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||||
|
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||||
|
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
const PARTY_COMMANDS: readonly {
|
const PARTY_COMMANDS: readonly {
|
||||||
command: PartyCommand;
|
command: PartyCommand;
|
||||||
@@ -230,6 +233,11 @@ export function PartyFrames() {
|
|||||||
.map((aura) => aura.definition.name)
|
.map((aura) => aura.definition.name)
|
||||||
.join(", "));
|
.join(", "));
|
||||||
const members = usePartyStore((state) => state.members);
|
const members = usePartyStore((state) => state.members);
|
||||||
|
const gameMode = useGameStore((state) => state.gameMode);
|
||||||
|
const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs));
|
||||||
|
const checkpoints = useResurrectionCheckpoints();
|
||||||
|
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||||
|
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||||
@@ -321,6 +329,25 @@ export function PartyFrames() {
|
|||||||
onSelect={() => selectMember(member.id)}
|
onSelect={() => selectMember(member.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
|
||||||
|
<div className="party-resurrection">
|
||||||
|
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
|
||||||
|
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="party-rez-button"
|
||||||
|
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
|
||||||
|
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
||||||
|
? "Only the party's simulation host can resurrect NPC teammates."
|
||||||
|
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
||||||
|
onClick={() => resurrectDeadPartyMembers(checkpointId)}
|
||||||
|
>
|
||||||
|
<strong>Rez dead members</strong>
|
||||||
|
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||||
|
? "Simulation host only" : "Then return to you"}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { activeDungeonCompletedObjectiveIds, activeDungeonEncounterRevision, subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
|
import { dungeonResurrectionCheckpoints, type ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
|
||||||
|
export function useResurrectionCheckpoints() {
|
||||||
|
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
|
const mobs = useCombatStore((state) => state.mobs);
|
||||||
|
const revision = useSyncExternalStore(subscribeDungeonEncounterSnapshots,
|
||||||
|
activeDungeonEncounterRevision, activeDungeonEncounterRevision);
|
||||||
|
return useMemo(() => dungeonResurrectionCheckpoints(requireDungeonDefinition(dungeonId), mobs,
|
||||||
|
activeDungeonCompletedObjectiveIds(dungeonId)), [dungeonId, mobs, revision]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResurrectionDestination({ checkpoints, value, onChange, disabled = false }: {
|
||||||
|
checkpoints: readonly ResurrectionCheckpoint[];
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="resurrection-destination">
|
||||||
|
<span>Resurrect at</span>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled}>
|
||||||
|
{checkpoints.map((checkpoint) => <option key={checkpoint.id} value={checkpoint.id}>{checkpoint.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -138,6 +138,23 @@ export function UiLayoutEditor() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||||
</details>
|
</details>
|
||||||
|
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||||
|
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||||
|
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
|
||||||
|
}))} />Show DPS meter</label>
|
||||||
|
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||||
|
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||||||
|
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
|
||||||
|
}))}>
|
||||||
|
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||||
|
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
|
||||||
|
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||||
|
dps: { ...layout.elements.dps, width: Number(event.target.value) },
|
||||||
|
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
|
||||||
|
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
|
||||||
|
</details>
|
||||||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||||||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createElement } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
|
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
|
||||||
|
import { ResurrectionDestination } from "./ResurrectionDestination";
|
||||||
|
|
||||||
describe("defeated prompt", () => {
|
describe("defeated prompt", () => {
|
||||||
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||||
@@ -25,6 +26,29 @@ describe("defeated prompt", () => {
|
|||||||
expect(markup).toContain("No shared resurrection charges remain");
|
expect(markup).toContain("No shared resurrection charges remain");
|
||||||
expect(markup).not.toContain("Resurrect now");
|
expect(markup).not.toContain("Resurrect now");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers labeled entrance and killed-boss choices on the player defeat prompt", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||||
|
gameMode: "dungeon", resurrectionCharges: 0, onReturn: () => undefined,
|
||||||
|
checkpoints: [
|
||||||
|
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||||
|
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(markup).toContain("Resurrect at");
|
||||||
|
expect(markup).toContain("value=\"boss:bazil\"");
|
||||||
|
expect(markup).toContain("Dungeon entrance");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains a selected boss destination in the shared chooser", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(ResurrectionDestination, {
|
||||||
|
checkpoints: [
|
||||||
|
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||||
|
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||||
|
], value: "boss:bazil", onChange: () => undefined,
|
||||||
|
}));
|
||||||
|
expect(markup).toContain("value=\"boss:bazil\" selected=\"\"");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dungeon completion prompt", () => {
|
describe("dungeon completion prompt", () => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.dps-meter {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
bottom: max(32px, env(safe-area-inset-bottom));
|
||||||
|
left: max(14px, env(safe-area-inset-left));
|
||||||
|
width: min(var(--dps-meter-width, 230px), calc(100% - 28px));
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--edge, #536344);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(5, 17, 12, .9);
|
||||||
|
color: var(--ink, #ece5d0);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.dps-meter > header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.dps-meter > header strong { color: var(--gold, #d5c28b); font: bold 13px Georgia, serif; }
|
||||||
|
.dps-meter > header small { flex: 1; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||||
|
.dps-meter > header span { font-size: 10px; }
|
||||||
|
.dps-meter > p { margin: 4px 0; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||||
|
.dps-meter > ol { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.dps-meter li { position: relative; display: flex; align-items: center; gap: 8px; min-height: 20px; padding: 2px 5px; overflow: hidden; border-radius: 3px; background: #ffffff06; }
|
||||||
|
.dps-meter li > i { position: absolute; inset: 0 auto 0 0; background: var(--dps-color); opacity: .3; }
|
||||||
|
.dps-meter li > span { position: relative; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dps-meter li > b { position: relative; font-size: 10px; }
|
||||||
|
.dps-meter > footer { display: flex; justify-content: space-between; gap: 5px; margin-top: 6px; color: var(--muted, #a4b69a); font-size: 9px; }
|
||||||
|
.dps-meter > footer b { color: var(--ink, #ece5d0); white-space: nowrap; }
|
||||||
|
.editable-hud--editing:has(> .dps-meter) { padding-top: 24px; }
|
||||||
|
.dps-meter--compact { position: relative; inset: auto; width: min(var(--dps-meter-width, 230px), 100%); max-height: 150px; padding: 5px; font-size: 8px; }
|
||||||
|
.dps-meter--compact > header { gap: 5px; margin-bottom: 3px; }
|
||||||
|
.dps-meter--compact > header strong { font-size: 10px; }
|
||||||
|
.dps-meter--compact > header :is(small, span), .dps-meter--compact > p { font-size: 7px; }
|
||||||
|
.dps-meter--compact > ol { gap: 2px; }
|
||||||
|
.dps-meter--compact li { min-height: 13px; padding: 1px 4px; }
|
||||||
|
.dps-meter--compact li > b { font-size: 7px; }
|
||||||
|
.dps-meter--compact > footer { margin-top: 3px; font-size: 6px; }
|
||||||
|
.companion-tactical__overview { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-width: 0; min-height: 0; }
|
||||||
|
.companion-tactical__overview > .companion-map-card { grid-template-columns: minmax(0, 1fr) 110px; min-height: 0; }
|
||||||
|
.ui-layout-editor__dps { padding: 5px 0; }
|
||||||
|
.ui-layout-editor__dps input[type="range"] { flex: 1; min-width: 0; }
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../game/dungeonEncounterRuntime";
|
||||||
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
|
import { usePartyStore } from "../game/partyStore";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
import { PartyFrames } from "./PartyFrames";
|
||||||
|
|
||||||
|
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
|
||||||
|
vi.mock("../game/partyStore", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../game/partyStore")>();
|
||||||
|
return { ...actual, usePartyStore: Object.assign(
|
||||||
|
(select: (state: ReturnType<typeof actual.usePartyStore.getState>) => unknown) => select(actual.usePartyStore.getState()), actual.usePartyStore,
|
||||||
|
) };
|
||||||
|
});
|
||||||
|
vi.mock("../game/combatStore", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../game/combatStore")>();
|
||||||
|
return { ...actual, useCombatStore: Object.assign(
|
||||||
|
(select: (state: ReturnType<typeof actual.useCombatStore.getState>) => unknown) => select(actual.useCombatStore.getState()), actual.useCombatStore,
|
||||||
|
) };
|
||||||
|
});
|
||||||
|
vi.mock("../game/store", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../game/store")>();
|
||||||
|
return { ...actual, useGameStore: Object.assign(
|
||||||
|
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
|
||||||
|
) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
const render = () => renderToStaticMarkup(createElement(PartyFrames));
|
||||||
|
function killMember() {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("party resurrection UI", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||||
|
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||||
|
resetDungeonEncounters("dungeon", dungeon.id);
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||||
|
useCombatStore.setState({ mobs: {} });
|
||||||
|
usePartyStore.getState().initializeParty("rez-ui", 16, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the control when all NPCs are alive, even if only the player is dead", () => {
|
||||||
|
expect(render()).not.toContain("Rez dead members");
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
expect(render()).not.toContain("Rez dead members");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places the button and labeled destination selector below every party frame", () => {
|
||||||
|
killMember();
|
||||||
|
const markup = render();
|
||||||
|
expect(markup).toContain("Rez dead members");
|
||||||
|
expect(markup).toContain("Resurrect at");
|
||||||
|
expect(markup).toContain("Dungeon entrance");
|
||||||
|
expect(markup.indexOf("party-resurrection")).toBeGreaterThan(markup.lastIndexOf("party-frame__identity"));
|
||||||
|
expect(markup).not.toMatch(/class="party-rez-button" disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows why resurrection is disabled during a living boss encounter", () => {
|
||||||
|
killMember();
|
||||||
|
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, maxHealth: 100 });
|
||||||
|
useCombatStore.getState().damageMob("boss", 1, 100);
|
||||||
|
expect(render()).toMatch(/class="party-rez-button" disabled=""/);
|
||||||
|
expect(render()).toContain("Unavailable during boss combat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers killed boss destinations but not bosses that are still alive", () => {
|
||||||
|
killMember();
|
||||||
|
const boss = dungeon.bosses[0];
|
||||||
|
expect(render()).not.toContain(`value="boss:${boss.id}"`);
|
||||||
|
useCombatStore.getState().registerMob(boss.id, { name: boss.name, boss: true, maxHealth: 1 });
|
||||||
|
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||||
|
expect(render()).toContain(`value="boss:${boss.id}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer free resurrection in Manastorm", () => {
|
||||||
|
killMember();
|
||||||
|
useGameStore.setState({ gameMode: "manastorm" });
|
||||||
|
expect(render()).not.toContain("Rez dead members");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user