From 361734bc41c46ddccca2d273a40c8831697c24e1 Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 12 Sep 2026 21:14:41 -0400 Subject: [PATCH] Release Healer Man 0.1.37 --- PLAYTEST_NOTES.md | 119 +++++++++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- src/GameRuntime.tsx | 2 + src/game/combatStore.ts | 119 ++++----- src/game/deathHealing.test.ts | 238 ++++++++++++++++++ src/game/deathRespawn.ts | 7 +- src/game/dpsMeter.test.ts | 42 ++++ src/game/dpsMeter.ts | 67 +++++ src/game/dpsMeterStore.test.ts | 64 +++++ src/game/dpsMeterStore.ts | 84 +++++++ src/game/partyRejoin.test.ts | 54 ++++ src/game/partyRejoin.ts | 33 +++ src/game/partyResurrection.test.ts | 131 ++++++++++ src/game/partyResurrection.ts | 39 +++ src/game/partyRuntime.test.ts | 32 +++ src/game/partyRuntime.ts | 7 +- src/game/partyRuntimeRegistry.test.ts | 30 +++ src/game/partyRuntimeRegistry.ts | 26 ++ src/game/resurrectionCheckpoints.test.ts | 78 ++++++ src/game/resurrectionCheckpoints.ts | 39 +++ src/game/stockadesNavigation.test.ts | 39 ++- src/game/uiLayout.test.ts | 23 +- src/game/uiLayout.ts | 15 +- .../authoritativeDualScreenRuntime.ts | 11 +- src/platform/dualScreenDps.test.ts | 48 ++++ src/platform/dualScreenSync.ts | 33 ++- src/scene/PartyPopulation.tsx | 64 ++++- src/styles.css | 10 + src/ui/CompanionPanel.tsx | 29 ++- src/ui/DpsMeter.tsx | 14 ++ src/ui/DpsMeterWidget.tsx | 20 ++ src/ui/EditableHudElement.tsx | 34 +-- src/ui/Hud.tsx | 31 ++- src/ui/PartyFrames.tsx | 31 ++- src/ui/ResurrectionDestination.tsx | 31 +++ src/ui/UiLayoutEditor.tsx | 17 ++ src/ui/defeatedPrompt.test.ts | 24 ++ src/ui/dpsMeter.css | 42 ++++ src/ui/partyResurrection.test.ts | 87 +++++++ 41 files changed, 1714 insertions(+), 110 deletions(-) create mode 100644 src/game/deathHealing.test.ts create mode 100644 src/game/dpsMeter.test.ts create mode 100644 src/game/dpsMeter.ts create mode 100644 src/game/dpsMeterStore.test.ts create mode 100644 src/game/dpsMeterStore.ts create mode 100644 src/game/partyRejoin.test.ts create mode 100644 src/game/partyRejoin.ts create mode 100644 src/game/partyResurrection.test.ts create mode 100644 src/game/partyResurrection.ts create mode 100644 src/game/resurrectionCheckpoints.test.ts create mode 100644 src/game/resurrectionCheckpoints.ts create mode 100644 src/platform/dualScreenDps.test.ts create mode 100644 src/ui/DpsMeter.tsx create mode 100644 src/ui/DpsMeterWidget.tsx create mode 100644 src/ui/ResurrectionDestination.tsx create mode 100644 src/ui/dpsMeter.css create mode 100644 src/ui/partyResurrection.test.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 6afaa210..108ee648 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -2439,3 +2439,122 @@ Vite's existing large-chunk advisory remains informational. tests remained skipped. Final production TypeScript/Vite compilation, texture and performance audits, static compression, and bundle verification passed. 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. diff --git a/package-lock.json b/package-lock.json index 96158822..112748f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.36", + "version": "0.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.36", + "version": "0.1.37", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 25bf344b..d42677ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.36", + "version": "0.1.37", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 7415a4da..48757859 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.36", - "versionCode": 1036 + "versionName": "0.1.37", + "versionCode": 1037 } diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx index ddc344d5..ff65fa62 100644 --- a/src/GameRuntime.tsx +++ b/src/GameRuntime.tsx @@ -3,6 +3,7 @@ import "./ui/uiLayout.css"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { installInput, releasePointerLock } from "./game/inputManager"; import { CombatBridge } from "./game/CombatBridge"; +import { connectDpsMeter } from "./game/dpsMeterStore"; import { useCombatStore } from "./game/combatStore"; import { releaseDungeonGltfCache } from "./game/dungeonAssetCache"; import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; @@ -93,6 +94,7 @@ function InputBridge() { } export default function GameRuntime() { + useEffect(connectDpsMeter, []); const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout"); useEffect(() => () => { const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 2854a3ec..7c6a83b1 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -2175,10 +2175,7 @@ function executeTriggeredAuraProcsInWork( const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum); } else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) { - const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health); - work.health += healed; - work.progression.health = work.health; - work.totalHealing += healed; + work.totalHealing += healPlayerInWork(work, action.amount); } else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) { work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount)); } else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) { @@ -2379,7 +2376,8 @@ function grantMobExperience( work.secondaryClassId, 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.maxHealth = progression.maxHealth; work.maxResource = talentAdjustedMaxResource( @@ -2877,10 +2875,7 @@ function executeTriggeredSpellInWork( "taken", )); if (recipient === PLAYER_AURA_ENTITY_ID) { - const healed = Math.min(amount, work.maxHealth - work.health); - work.health += healed; - work.progression.health = work.health; - work.totalHealing += healed; + work.totalHealing += healPlayerInWork(work, amount); } else { const healed = healCompanionActorInWork(work, recipient, amount); work.totalHealing += healed; @@ -3188,6 +3183,18 @@ function applyAuraHealthTransitionInWork( 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 { const actor = ensureCompanionActorInWork(work, id); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); @@ -4700,7 +4707,7 @@ export const useCombatStore = create((set, get) => ({ ? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health) : friendlyOnline ? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health) - : resolution.effectiveAmount; + : healPlayerInWork(work, resolution.rawAmount); if (friendlySummon && healed > 0) { work.summons = work.summons.map((summon) => summon.id === healingTargetId ? { ...summon, health: summon.health + healed } @@ -4710,9 +4717,6 @@ export const useCombatStore = create((set, get) => ({ } else if (friendlyOnline && healed > 0) { const actor = work.actors[healingTargetId] ?? friendlyOnline; work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true }; - } else if (!friendlyTarget) { - work.health += healed; - work.progression.health = work.health; } recordCombatEventInWork(work, { occurredAt: now, @@ -4744,10 +4748,7 @@ export const useCombatStore = create((set, get) => ({ && aura.definition.utilityTags?.includes("beacon-of-light") )); if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) { - const beaconHealing = Math.min(directHealing, work.maxHealth - work.health); - work.health += beaconHealing; - work.progression.health = work.health; - work.totalHealing += beaconHealing; + work.totalHealing += healPlayerInWork(work, directHealing); } else if (beacon) { work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing); } @@ -5331,10 +5332,7 @@ export const useCombatStore = create((set, get) => ({ work.resource = clamp(work.resource + restored, 0, work.maxResource); work.resourcePools[activeResourceType] = work.resource; } 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.health += healed; - work.progression.health = work.health; - work.totalHealing += healed; + work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25))); } } else if (effect.action === "portal") { summonSequence += 1; @@ -5363,13 +5361,7 @@ export const useCombatStore = create((set, get) => ({ aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("rom-revival-charge") )); - const restored = Math.min( - work.maxHealth - work.health, - Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)), - ); - work.health += restored; - work.progression.health = work.health; - work.totalHealing += restored; + work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0))); if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId); } } @@ -5412,9 +5404,8 @@ export const useCombatStore = create((set, get) => ({ // 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")) { - const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health); - work.health += healed; - work.progression.health = work.health; + const healed = healPlayerInWork(work, + talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability)); work.totalHealing += healed; addHealingThreatInWork(work, castThreatSource, healed); } @@ -5874,10 +5865,15 @@ export const useCombatStore = create((set, get) => ({ if (id === PLAYER_AURA_ENTITY_ID) { const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction)); if (restored <= 0) return 0; + const playerActor = state.actors[id]; set({ health: restored, controlledUntil: 0, 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 }), combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, @@ -6101,7 +6097,12 @@ export const useCombatStore = create((set, get) => ({ const state = get(); if (!state.actors[sourceActorId]) 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 sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const amount = sourceMember @@ -6402,11 +6403,15 @@ export const useCombatStore = create((set, get) => ({ const restored = Math.max(1, Math.round( state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)), )); + const playerActor = state.actors[PLAYER_AURA_ENTITY_ID]; set({ health: restored, controlledUntil: 0, controlMechanic: null, 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 }), }); return restored; @@ -7578,7 +7583,7 @@ export const useCombatStore = create((set, get) => ({ combatEvents: [...state.combatEvents], actors: { ...state.actors }, cooldowns: { ...state.cooldowns }, - health: Math.min( + health: state.health <= 0 ? 0 : Math.min( state.maxHealth, state.health + resolveAuraStatValue( state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01, @@ -7707,10 +7712,24 @@ export const useCombatStore = create((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) { + // Check before due ticks/procs, not only after applying their health changes. + if (!timedEffectTargetAlive(effect)) continue; let remainingTicks = effect.remainingTicks; let nextTickAt = effect.nextTickAt; - while (remainingTicks > 0 && nextTickAt <= now) { + while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) { const ability = abilityById(effect.sourceAbilityId); const baseAmount = effect.fixedAmount ?? (ability ? equipmentAdjustedCombatAmount( @@ -7764,10 +7783,10 @@ export const useCombatStore = create((set, get) => ({ "taken", )); if (effect.targetId) { - const target = work.actors[effect.targetId] - ?? ensureCompanionActorInWork(work, effect.targetId) - ?? (work.summons.some((summon) => summon.id === effect.targetId) - ? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras) + const target = ensureCompanionActorInWork(work, hotTargetId) + ?? work.actors[hotTargetId] + ?? (work.summons.some((summon) => summon.id === hotTargetId) + ? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras) : null); if (target?.alive) partyHotTicks.push({ memberId: target.id, @@ -7776,9 +7795,7 @@ export const useCombatStore = create((set, get) => ({ sourceAbilityId: effect.sourceAbilityId, }); } else { - const healed = Math.min(modifiedHealing, work.maxHealth - work.health); - work.health += healed; - work.progression.health = work.health; + const healed = healPlayerInWork(work, modifiedHealing); work.totalHealing += healed; addHealingThreatInWork( work, @@ -7823,12 +7840,7 @@ export const useCombatStore = create((set, get) => ({ remainingTicks -= 1; nextTickAt += effect.intervalMs; } - const targetAlive = effect.kind === "dot" - ? 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) { + if (remainingTicks > 0 && timedEffectTargetAlive(effect)) { work.effects.push( remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt ? effect @@ -7884,13 +7896,10 @@ export const useCombatStore = create((set, get) => ({ || Math.sqrt(distanceSquared(area.position, position)) > area.radius || !combatHasLineOfSight(area.position, position)) continue; const member = partyMembers.find((candidate) => candidate.id === friendlyId); + if (member ? member.health <= 0 : work.health <= 0) continue; const healed = member ? healCompanionActorInWork(work, friendlyId, amount) - : Math.min(amount, work.maxHealth - work.health); - if (!member) { - work.health += healed; - work.progression.health = work.health; - } + : healPlayerInWork(work, amount); work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: nextTickAt, @@ -7941,12 +7950,8 @@ export const useCombatStore = create((set, get) => ({ const target = injured[0]; if (target) { 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); - if (target.id === PLAYER_AURA_ENTITY_ID) { - work.health += healed; - work.progression.health = work.health; - } work.totalHealing += healed; addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed); recordCombatEventInWork(work, { diff --git a/src/game/deathHealing.test.ts b/src/game/deathHealing.test.ts new file mode 100644 index 00000000..d5fe8452 --- /dev/null +++ b/src/game/deathHealing.test.ts @@ -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 }); + }); +}); diff --git a/src/game/deathRespawn.ts b/src/game/deathRespawn.ts index 4e32f638..bf424ebd 100644 --- a/src/game/deathRespawn.ts +++ b/src/game/deathRespawn.ts @@ -1,6 +1,7 @@ import { useCombatStore } from "./combatStore"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { useGameStore } from "./store"; +import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints"; import { activeDungeonCompletedObjectiveIds, resetDungeonEncounters, @@ -11,9 +12,11 @@ import { * current pull. Defeated enemies and persisted dungeon progress remain clear, * 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(); 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 completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId)); @@ -31,6 +34,8 @@ export function respawnDefeatedPlayer(): boolean { if (typeof document !== "undefined" && document.pointerLockElement) { document.exitPointerLock(); } + // Select the saved return spawn without relocating first or resetting twice. + if (checkpoint) useGameStore.setState({ activeSpawn: checkpoint.spawn }); useGameStore.getState().resetAfterDefeat(); resetDungeonEncounters("boss-wipe", dungeonId); const respawnPosition = useGameStore.getState().playerPosition; diff --git a/src/game/dpsMeter.test.ts b/src/game/dpsMeter.test.ts new file mode 100644 index 00000000..cca94adc --- /dev/null +++ b/src/game/dpsMeter.test.ts @@ -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); + }); +}); diff --git a/src/game/dpsMeter.ts b/src/game/dpsMeter.ts new file mode 100644 index 00000000..bcd9c2ca --- /dev/null +++ b/src/game/dpsMeter.ts @@ -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>; +} + +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 })) }; +} diff --git a/src/game/dpsMeterStore.test.ts b/src/game/dpsMeterStore.test.ts new file mode 100644 index 00000000..a1672c1c --- /dev/null +++ b/src/game/dpsMeterStore.test.ts @@ -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 } }); + }); +}); diff --git a/src/game/dpsMeterStore.ts b/src/game/dpsMeterStore.ts new file mode 100644 index 00000000..14cfffca --- /dev/null +++ b/src/game/dpsMeterStore.ts @@ -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(); + 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); + }; +} diff --git a/src/game/partyRejoin.test.ts b/src/game/partyRejoin.test.ts new file mode 100644 index 00000000..8bce630e --- /dev/null +++ b/src/game/partyRejoin.test.ts @@ -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); + }); +}); diff --git a/src/game/partyRejoin.ts b/src/game/partyRejoin.ts new file mode 100644 index 00000000..9fb10b25 --- /dev/null +++ b/src/game/partyRejoin.ts @@ -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, +) { + 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 }; +} diff --git a/src/game/partyResurrection.test.ts b/src/game/partyResurrection.test.ts new file mode 100644 index 00000000..ea831650 --- /dev/null +++ b/src/game/partyResurrection.test.ts @@ -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); + }); +}); diff --git a/src/game/partyResurrection.ts b/src/game/partyResurrection.ts new file mode 100644 index 00000000..92d0fecf --- /dev/null +++ b/src/game/partyResurrection.ts @@ -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>): 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; +} diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts index 7153c4b9..93dbf036 100644 --- a/src/game/partyRuntime.test.ts +++ b/src/game/partyRuntime.test.ts @@ -6,6 +6,8 @@ import { registerPartyRuntimePosition, updatePartyRuntimeAttackCorridor, updatePartyRuntimeTargetVisibility, + beginPartyRuntimeRejoin, + finishPartyRuntimeRejoin, } from "./partyRuntimeRegistry"; import { activePartyTargetId, @@ -47,6 +49,36 @@ describe("party combat runtime", () => { 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", () => { registerEngagedMob(); for (const member of usePartyStore.getState().members) { diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index 95559c5f..7533ae46 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -21,6 +21,7 @@ import { requireDungeonDefinition } from "./dungeonRegistry"; import { getMobPosition } from "./mobRuntimeRegistry"; import { getPartyRuntimePosition, + partyRuntimeIsRejoining, partyRuntimeCenter, partyRuntimePositionAlongAttackCorridor, partyRuntimeTargetVisible, @@ -1058,7 +1059,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { )); let members: readonly PartyMember[] = authoritativeMembers; const partyOrigin = partyRuntimeCenter( - members.map((member) => member.id), + members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id), combat.playerPosition, ); let targetId = partyCommandTargetId( @@ -1122,6 +1123,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { if (member.status !== "down") nextMembers[index] = { ...member, status: "down" }; continue; } + if (partyRuntimeIsRejoining(member.id)) { + nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null }; + continue; + } if (member.controlledUntil > now) { nextMembers[index] = member.status === "holding" ? member diff --git a/src/game/partyRuntimeRegistry.test.ts b/src/game/partyRuntimeRegistry.test.ts index f780ceab..2c02a805 100644 --- a/src/game/partyRuntimeRegistry.test.ts +++ b/src/game/partyRuntimeRegistry.test.ts @@ -11,11 +11,41 @@ import { updatePartyRuntimeTargetVisibility, updatePartyRuntimeAttackCorridor, updatePartyRuntimePosition, + beginPartyRuntimeRejoin, + consumePartyRuntimeRejoin, + partyRuntimeIsRejoining, + finishPartyRuntimeRejoin, } from "./partyRuntimeRegistry"; describe("party runtime position registry", () => { 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", () => { const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]); updatePartyRuntimePosition("tank", 4, 5, 6); diff --git a/src/game/partyRuntimeRegistry.ts b/src/game/partyRuntimeRegistry.ts index 2a9b5c3c..8654a216 100644 --- a/src/game/partyRuntimeRegistry.ts +++ b/src/game/partyRuntimeRegistry.ts @@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number]; const positions = new Map(); const targetVisibility = new Map(); +const rejoins = new Map(); + +/** 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: { readonly origin: MutablePartyWorldPosition; readonly waypoints: readonly PartyWorldPosition[]; @@ -21,6 +44,7 @@ export function registerPartyRuntimePosition( return () => { positions.delete(id); targetVisibility.delete(id); + rejoins.delete(id); }; } @@ -111,6 +135,7 @@ export function updatePartyRuntimePosition( export function removePartyRuntimePosition(id: string): void { positions.delete(id); targetVisibility.delete(id); + rejoins.delete(id); } export function getPartyRuntimePosition(id: string): PartyWorldPosition | null { @@ -166,5 +191,6 @@ export function partyRuntimeCenter( export function clearPartyRuntimeRegistry(): void { positions.clear(); targetVisibility.clear(); + rejoins.clear(); attackCorridor = null; } diff --git a/src/game/resurrectionCheckpoints.test.ts b/src/game/resurrectionCheckpoints.test.ts new file mode 100644 index 00000000..98bd5205 --- /dev/null +++ b/src/game/resurrectionCheckpoints.test.ts @@ -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); + }); +}); diff --git a/src/game/resurrectionCheckpoints.ts b/src/game/resurrectionCheckpoints.ts new file mode 100644 index 00000000..bab3b9f7 --- /dev/null +++ b/src/game/resurrectionCheckpoints.ts @@ -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>, + 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; +} diff --git a/src/game/stockadesNavigation.test.ts b/src/game/stockadesNavigation.test.ts index 4c92331e..1a4e9c84 100644 --- a/src/game/stockadesNavigation.test.ts +++ b/src/game/stockadesNavigation.test.ts @@ -13,7 +13,8 @@ import { } from "./partyNavigation"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; import { shouldLoadPartyNavigation } from "./partyNavigationPolicy"; -import { planPartyRoute } from "./partyPathing"; +import { emptyPartyRouteState, planPartyRoute } from "./partyPathing"; +import { partyRejoinMovement } from "./partyRejoin"; import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement"; import { derivePartySpawnSlots } from "./partySpawnSlots"; 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", () => { + 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", () => { const world = collisionWorld(true, true); try { diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts index 9f1ff464..6929c5b1 100644 --- a/src/game/uiLayout.test.ts +++ b/src/game/uiLayout.test.ts @@ -1,10 +1,31 @@ import { describe, expect, it } from "vitest"; import { actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, - exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, + exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay, } from "./uiLayout"; 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", () => { const ui = normalizeUiPreferences(undefined); expect(ui.slots.mobile).toEqual([null, null, null]); diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts index 862e9656..c2d885ca 100644 --- a/src/game/uiLayout.ts +++ b/src/game/uiLayout.ts @@ -7,7 +7,7 @@ export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const; export const HUD_ELEMENTS = { party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", 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", reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", 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 UiLayout { elements: Record; + dpsMeterDisplay: "auto" | "main" | "companion"; wowBars: boolean; barCount: number; bars: DesktopActionBar[]; @@ -42,6 +43,7 @@ export function defaultUiLayout(): UiLayout { elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, { visible: true, position: null, width: null, }])) as UiLayout["elements"], + dpsMeterDisplay: "auto", wowBars: false, barCount: 1, 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 { 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."); const validateLayout = (value: unknown) => { 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 || !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration."); 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 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)) diff --git a/src/platform/authoritativeDualScreenRuntime.ts b/src/platform/authoritativeDualScreenRuntime.ts index 97b5e4ed..7ba33920 100644 --- a/src/platform/authoritativeDualScreenRuntime.ts +++ b/src/platform/authoritativeDualScreenRuntime.ts @@ -5,6 +5,8 @@ import { useShellStore } from "../app/shellStore"; import { emitControllerToken, setExternalControllerMovement } from "../input/controller"; import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessage } from "./dualScreenSync"; import { createRateLimitedPublisher } from "./rateLimitedPublisher"; +import { useUiLayoutStore } from "../game/uiLayoutStore"; +import { useDpsMeterStore } from "../game/dpsMeterStore"; export function connectAuthoritativeDualScreen( channel: BroadcastChannel, @@ -30,6 +32,12 @@ export function connectAuthoritativeDualScreen( const unsubscribeParty = usePartyStore.subscribe(() => { if (companionReady) gamePublisher.request(); }); + const unsubscribeUi = useUiLayoutStore.subscribe(() => { + if (companionReady) gamePublisher.request(); + }); + const unsubscribeDps = useDpsMeterStore.subscribe(() => { + if (companionReady) gamePublisher.request(); + }); channel.onmessage = (event: MessageEvent) => { if (event.data.type === "companion-ready") { companionReady = true; @@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen( unsubscribeGame(); unsubscribeCombat(); unsubscribeParty(); + unsubscribeUi(); + unsubscribeDps(); gamePublisher.dispose(); }; } - diff --git a/src/platform/dualScreenDps.test.ts b/src/platform/dualScreenDps.test.ts new file mode 100644 index 00000000..08da3bb3 --- /dev/null +++ b/src/platform/dualScreenDps.test.ts @@ -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); + 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(); + }); +}); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 49d82034..f3b84f8c 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -26,6 +26,12 @@ import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../ import type { DungeonId } from "../game/dungeonCatalog"; import { useManastormStore } from "../game/manastormStore"; 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"; @@ -89,6 +95,12 @@ export interface CompanionPartyObjectiveSnapshot { } export interface CompanionGameSnapshot { + dpsMeter: { + values: DpsMeterSnapshot; + layout: HudElementLayout; + display: "main" | "companion"; + editing: boolean; + }; gameMode: GameMode; dungeonId: string; dungeonTitle: string; @@ -159,7 +171,8 @@ export type CompanionCommand = | { name: "cyclePartyMember"; direction: 1 | -1 } | { name: "setPartyCommand"; command: PartyCommand } | { name: "recallParty" } - | { name: "resetAtEntrance" }; + | { name: "resetAtEntrance" } + | { name: "editDpsMeter"; update: Partial }; export type DualScreenMessage = | { type: "companion-ready" } @@ -180,6 +193,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const manastorm = useManastormStore.getState(); + const uiLayout = activeUiLayout(); const dungeon = requireDungeonDefinition(game.activeDungeonId); const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, { title: dungeon.title, @@ -251,6 +265,12 @@ export function currentCompanionSnapshot(): CompanionSnapshot { draft: shell.draft, notice: shell.notice, game: { + dpsMeter: { + values: currentDpsMeterSnapshot(), + layout: uiLayout.elements.dps, + display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"), + editing: game.overlay === "ui-layout", + }, gameMode: game.gameMode, dungeonId: dungeon.id, dungeonTitle: stagePresentation.title, @@ -322,6 +342,17 @@ export function executeCompanionCommand(command: CompanionCommand): void { const game = useGameStore.getState(); const combat = useCombatStore.getState(); switch (command.name) { + case "editDpsMeter": { + if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break; + const update: Partial = {}; + 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 "openCreator": shell.openCreator(); break; case "cancelCreator": shell.cancelCreator(); break; diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index 8bfcce46..308cdaf9 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -72,10 +72,14 @@ import { type PartyRouteState, } from "../game/partyPathing"; import { partyCombatRangeBand } from "../game/partyRuntime"; +import { partyRejoinMovement } from "../game/partyRejoin"; import { useWailingEncounterStore } from "../game/wailingCavernsEncounter"; import { clearPartyRuntimeAttackCorridor, + consumePartyRuntimeRejoin, + finishPartyRuntimeRejoin, getPartyRuntimePosition, + partyRuntimeIsRejoining, registerPartyRuntimePosition, updatePartyRuntimeAttackCorridor, updatePartyRuntimePosition, @@ -419,6 +423,7 @@ function PartyActors({ : null; const recallPosition = navigationRecall?.position ?? player; party.members.forEach((member, index) => { + finishPartyRuntimeRejoin(member.id); const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); // Attach a recalled squad to the reviewed surface just as initial // spawns do. An exact visual-floor point can sit between Recast stair @@ -518,6 +523,7 @@ function PartyActors({ members.forEach((member, index) => { const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const slot = spawnSlots[index + 1] ?? spawnSlots[0]; + finishPartyRuntimeRejoin(member.id); position[0] = slot.footPosition[0]; position[1] = slot.footPosition[1]; position[2] = slot.footPosition[2]; @@ -605,6 +611,29 @@ function PartyActors({ appendPartyBreadcrumb(playerTrailRef.current, playerNavigation); const routeNow = clock.elapsedTime * 1_000; 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 latestPlayerTrailPoint = playerTrail[playerTrail.length - 1]; const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2]; @@ -639,7 +668,7 @@ function PartyActors({ lastLeaderWaypointRef.current = 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 desired: MutablePartyWorldPosition = [0, 0, 0]; const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; @@ -994,7 +1023,8 @@ function PartyActors({ } lastHealthRef.current.set(member.id, member.health); 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 canSeeActiveTarget = Boolean( memberTargetId @@ -1012,10 +1042,28 @@ function PartyActors({ if (member.health <= 0) { const signal = movingSignalsRef.current.get(member.id); 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) { // 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) { hasDesired = leaderHasWaypoint; movementExpected = leaderMovementExpected; @@ -1254,7 +1302,7 @@ function PartyActors({ const movementProfile = movementProfileFor(member.id, index); 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) * movementProfile.speedMultiplier; const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition); @@ -1287,7 +1335,7 @@ function PartyActors({ stuckProgress = createPartyStuckRecoveryState(current, routeNow); 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}`; const recovery = updatePartyStuckRecovery(stuckProgress, { position: current, @@ -1296,7 +1344,7 @@ function PartyActors({ movementExpected, health: member.health, paused: !active || game.paused || game.mapOpen || game.companionOpen, - stopped: party.command === "stop", + stopped: party.command === "stop" && !rejoining, casting: member.activeCast !== null, controlled: member.controlledUntil > combatNow, nowMs: routeNow, @@ -1316,7 +1364,7 @@ function PartyActors({ const landing = choosePartyStuckRecoveryPosition({ current, 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), validate: recoveryPointAllowed, }); diff --git a/src/styles.css b/src/styles.css index d03318bb..d7dbd8ee 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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-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-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.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)); } diff --git a/src/ui/CompanionPanel.tsx b/src/ui/CompanionPanel.tsx index 6a9dabbb..08052e03 100644 --- a/src/ui/CompanionPanel.tsx +++ b/src/ui/CompanionPanel.tsx @@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar"; import { BrandMark, FrontSurface } from "./FrontSurface"; import { ShelvedCompanionPanel } from "@shelvedCompanionPanel"; 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 { if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; @@ -456,14 +460,21 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false } -
- -
- {snapshot.game.overlay ? "Upper display" : "Current area"} - {snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName} - {Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees -
-
+
+
+ +
+ {snapshot.game.overlay ? "Upper display" : "Current area"} + {snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName} + {Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees +
+
+ {snapshot.game.dpsMeter.display === "companion" && send({ name: "editDpsMeter", update })}> + + } +
; } diff --git a/src/ui/DpsMeter.tsx b/src/ui/DpsMeter.tsx new file mode 100644 index 00000000..d5baaad9 --- /dev/null +++ b/src/ui/DpsMeter.tsx @@ -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 ; +} diff --git a/src/ui/DpsMeterWidget.tsx b/src/ui/DpsMeterWidget.tsx new file mode 100644 index 00000000..fe8d1363 --- /dev/null +++ b/src/ui/DpsMeterWidget.tsx @@ -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
+
DPS{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"} + {Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}
+ {snapshot.rows.length > 0 ?
    {snapshot.rows.map((row) =>
  1. +