Release Healer Man 0.1.37

This commit is contained in:
phenom committed 2026-09-12 21:14:41 -04:00
1 parent 03a4e3f51c
commit 361734bc41
41 files changed
+1714 -110

No files matched your search

+2
View File
@@ -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);
+62 -57
View File
@@ -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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((set, get) => ({
}
}
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
if (effect.kind === "dot") {
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
return Boolean(mob && !mob.dead && mob.health > 0);
}
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
const summon = work.summons.find((candidate) => candidate.id === targetId);
if (summon) return summon.health > 0;
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
return Boolean(actor?.alive && actor.health > 0);
};
for (const effect of state.effects) {
// 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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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, {
+238
View File
@@ -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 });
});
});
+6 -1
View File
@@ -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;
+42
View File
@@ -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);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { StructuredCombatEvent } from "./combatEvents";
export interface DpsMeterActor {
readonly id: string;
readonly name: string;
readonly color: string;
}
export interface DpsMeterState {
readonly startedAt: number | null;
readonly endedAt: number | null;
readonly updatedAt: number | null;
readonly elapsedMs: number;
readonly damage: Readonly<Record<string, DpsMeterActor & { total: number }>>;
}
export interface DpsMeterRow extends DpsMeterActor {
readonly total: number;
readonly dps: number;
readonly percent: number;
}
export interface DpsMeterSnapshot {
readonly active: boolean;
readonly durationSeconds: number;
readonly totalDamage: number;
readonly totalDps: number;
readonly rows: readonly DpsMeterRow[];
}
export function emptyDpsMeter(): DpsMeterState {
return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} };
}
/** Keep the last pull visible until the next starts; menus do not add combat time. */
export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState {
if (!Number.isFinite(now)) return state;
if (state.startedAt === null || state.endedAt !== null) {
return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state;
}
const updatedAt = Math.max(state.updatedAt ?? now, now);
return { ...state, updatedAt,
elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)),
endedAt: inCombat ? null : updatedAt };
}
/** Consume the lossless stream, never the truncated combat-log history. */
export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState {
if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state;
const next = advanceDpsMeter(state, event.occurredAt, true);
return { ...next, damage: { ...next.damage,
[actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } };
}
export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot {
const durationSeconds = state.elapsedMs / 1_000;
const seconds = Math.max(1, durationSeconds);
const damage = state.startedAt === null ? {} : {
...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage,
};
const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
const maximum = Math.max(1, ordered[0]?.total ?? 0);
const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0);
return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage,
totalDps: totalDamage / seconds,
rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) };
}
+64
View File
@@ -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 } });
});
});
+84
View File
@@ -0,0 +1,84 @@
import { create } from "zustand";
import { classById, CLASSES } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter";
import { onlinePlayerActorId } from "./onlineSessionRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
export const useDpsMeterStore = create(() => emptyDpsMeter());
function meterActors(): DpsMeterActor[] {
const combat = useCombatStore.getState();
const online = useOnlineSessionStore.getState().snapshot;
return [
{ id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color },
...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })),
...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({
id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username,
color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b",
})),
];
}
export function dpsSourceActor(sourceId: string): DpsMeterActor | null {
const combat = useCombatStore.getState();
const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId;
const visited = new Set<string>();
let id = sourceId;
while (!visited.has(id)) {
visited.add(id);
if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID;
const known = meterActors().find((actor) => actor.id === id);
if (known) return known;
const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
if (!ownerId) return null;
id = ownerId;
}
return null;
}
export function currentDpsMeterSnapshot() {
return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors());
}
/** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */
export function connectDpsMeter(): () => void {
useDpsMeterStore.setState(emptyDpsMeter(), true);
let suspendedPreviously = false;
const sample = () => {
const combat = useCombatStore.getState();
const game = useGameStore.getState();
const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot);
const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden));
const state = useDpsMeterStore.getState();
const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously);
suspendedPreviously = suspended;
if (next !== state) useDpsMeterStore.setState(next, true);
};
const unsubscribeEvents = subscribeCombatEvents((event) => {
if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return;
const actor = dpsSourceActor(event.sourceActorId);
if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true);
});
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) {
useDpsMeterStore.setState(emptyDpsMeter(), true);
} else sample();
});
const unsubscribeGame = useGameStore.subscribe(sample);
if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample);
const timer = setInterval(sample, 250);
sample();
return () => {
clearInterval(timer);
unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame();
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample);
useDpsMeterStore.setState(emptyDpsMeter(), true);
};
}
+54
View File
@@ -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);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "./partyNavigation";
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteOptions, type PartyRouteState } from "./partyPathing";
import type { PartyWorldPosition } from "./partyRuntimeRegistry";
export const PARTY_REJOIN_DISTANCE = 3;
/** Returning resurrected members target the player, not a distant boss or leader. */
export function partyRejoinMovement(
id: string, current: PartyWorldPosition, player: PartyWorldPosition, nowMs: number,
graph: PartyNavigationGraph | null, route: PartyRouteState,
options: Pick<PartyRouteOptions, "directSegmentAllowed" | "routeSegmentAllowed">,
) {
const reachedPlayer = (point: PartyWorldPosition) => Math.hypot(
point[0] - player[0], point[1] - player[1], point[2] - player[2],
) <= PARTY_REJOIN_DISTANCE && Boolean(options.directSegmentAllowed?.(point, player));
if (reachedPlayer(current)) return { rejoined: true, route: emptyPartyRouteState(), waypoint: null, blocked: false };
if (options.directSegmentAllowed?.(current, player)) {
return { rejoined: false, route: emptyPartyRouteState(), waypoint: player, blocked: false };
}
const destination = graph ? findNearestPartyNavigationSurface(graph, player, 5)?.position ?? player : player;
const goalKey = `member:${id}|resurrection-rejoin`;
if (partyRouteNeedsPlanning(route, goalKey, current, destination, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
route = planPartyRoute(graph, goalKey, current, destination, nowMs, {
...options, maxOriginSnapDistance: 6, maxDestinationSnapDistance: 5,
localFinishDistance: PARTY_REJOIN_DISTANCE,
destinationReached: reachedPlayer,
});
}
const waypoint: [number, number, number] = [0, 0, 0];
const hasWaypoint = nextPartyRouteWaypoint(route, current, waypoint)
|| nextPartyRouteRecoveryWaypoint(route, current, waypoint, options.directSegmentAllowed ?? (() => false));
return { rejoined: false, route, waypoint: hasWaypoint ? waypoint : null, blocked: route.blocked && !hasWaypoint };
}
+131
View File
@@ -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);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore, type MobCombatState } from "./combatStore";
import { onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
import { beginPartyRuntimeRejoin } from "./partyRuntimeRegistry";
import { derivePartySpawnSlots } from "./partySpawnSlots";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
export function partyResurrectionBlockedByBoss(mobs: Readonly<Record<string, MobCombatState>>): boolean {
return Object.values(mobs).some((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned);
}
/** Normal-dungeon recovery only; never bypass Manastorm charges or online authority. */
export function resurrectDeadPartyMembers(checkpointId = "entrance", now = Date.now()): number {
const game = useGameStore.getState();
const combat = useCombatStore.getState();
if (game.gameMode !== "dungeon" || !Number.isFinite(now) || !onlineSessionOwnsWorldSimulation()
|| partyResurrectionBlockedByBoss(combat.mobs)) return 0;
const checkpoint = resolveResurrectionCheckpoint(checkpointId);
if (!checkpoint) return 0;
const party = usePartyStore.getState();
const fallen = party.members.filter((member) => (combat.actors[member.id]?.health ?? member.health) <= 0);
if (!fallen.length) return 0;
const ids = new Set(fallen.map((member) => member.id));
const slots = derivePartySpawnSlots(checkpoint.spawn, party.members.length + 1);
useCombatStore.setState({ effects: combat.effects.filter((effect) => !effect.targetId || !ids.has(effect.targetId)) });
let restoredCount = 0;
for (const member of fallen) {
if (useCombatStore.getState().reviveActor(member.id, 1, PLAYER_AGGRO_ID, "party-checkpoint-resurrection", now) <= 0) continue;
const index = party.members.findIndex((candidate) => candidate.id === member.id);
beginPartyRuntimeRejoin(member.id, (slots[index + 1] ?? slots[0]).footPosition);
usePartyStore.getState().updateMemberCombat(member.id, {
status: "advancing", activeCast: null, activeCastTargetId: null, controlledUntil: 0, controlMechanic: null,
});
restoredCount += 1;
}
return restoredCount;
}
+32
View File
@@ -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) {
+6 -1
View File
@@ -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
+30
View File
@@ -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);
+26
View File
@@ -3,6 +3,29 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
const positions = new Map<string, MutablePartyWorldPosition>();
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
/** A one-shot scene relocation followed by an individual return-to-player task. */
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
rejoins.set(id, { position: [...position], pending: true });
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
targetVisibility.delete(id);
}
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
const rejoin = rejoins.get(id);
if (!rejoin?.pending) return null;
rejoin.pending = false;
return [...rejoin.position];
}
export function partyRuntimeIsRejoining(id: string): boolean {
return rejoins.has(id);
}
export function finishPartyRuntimeRejoin(id: string): void {
rejoins.delete(id);
}
let attackCorridor: {
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;
}
+78
View File
@@ -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);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { useCombatStore, type MobCombatState } from "./combatStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import type { DungeonDefinition, WorldSpawn } from "./dungeonTypes";
import { useGameStore } from "./store";
export interface ResurrectionCheckpoint {
readonly id: string;
readonly name: string;
readonly spawn: WorldSpawn;
}
/** Authored boss positions only: never accept a corpse position or arbitrary UI coordinates. */
export function dungeonResurrectionCheckpoints(
dungeon: DungeonDefinition,
mobs: Readonly<Record<string, MobCombatState>>,
completedObjectiveIds: readonly string[] = [],
): ResurrectionCheckpoint[] {
const completed = new Set(completedObjectiveIds);
return [
{ id: "entrance", name: "Dungeon entrance", spawn: dungeon.entrance },
...dungeon.bosses.filter((boss) => completed.has(boss.id)
|| (mobs[boss.id]?.dead && !mobs[boss.id]?.despawned)).map((boss) => ({
id: `boss:${boss.id}`, name: boss.name,
spawn: { footPosition: boss.position, yaw: dungeon.entrance.yaw },
})),
];
}
export function availableResurrectionCheckpoints(): ResurrectionCheckpoint[] {
const game = useGameStore.getState();
return dungeonResurrectionCheckpoints(requireDungeonDefinition(game.activeDungeonId),
useCombatStore.getState().mobs, activeDungeonCompletedObjectiveIds(game.activeDungeonId));
}
export function resolveResurrectionCheckpoint(id: string): ResurrectionCheckpoint | null {
if (useGameStore.getState().gameMode !== "dungeon") return null;
return availableResurrectionCheckpoints().find((checkpoint) => checkpoint.id === id) ?? null;
}
+38 -1
View File
@@ -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 {
+22 -1
View File
@@ -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]);
+13 -2
View File
@@ -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<HudElementId, HudElementLayout>;
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))
+10 -1
View File
@@ -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<DualScreenMessage>) => {
if (event.data.type === "companion-ready") {
companionReady = true;
@@ -52,7 +60,8 @@ export function connectAuthoritativeDualScreen(
unsubscribeGame();
unsubscribeCombat();
unsubscribeParty();
unsubscribeUi();
unsubscribeDps();
gamePublisher.dispose();
};
}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { connectAuthoritativeDualScreen } from "./authoritativeDualScreenRuntime";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { emptyDpsMeter } from "../game/dpsMeter";
import type { DualScreenMessage } from "./dualScreenSync";
describe("dedicated companion DPS synchronization", () => {
afterEach(() => {
useUiLayoutStore.getState().finish(false);
useDpsMeterStore.setState(emptyDpsMeter(), true);
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("publishes meter ticks and layout drafts, then unsubscribes on disconnect", () => {
vi.useFakeTimers(); vi.setSystemTime(1_000);
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { search: "?display=top" } });
const postMessage = vi.fn<(message: DualScreenMessage) => void>();
const channel = { postMessage, onmessage: null } as unknown as BroadcastChannel;
const disconnect = connectAuthoritativeDualScreen(channel, { data: { type: "companion-ready" } } as MessageEvent<DualScreenMessage>);
try {
useUiLayoutStore.getState().begin();
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, dpsMeterDisplay: "companion", elements: {
...layout.elements, dps: { visible: false, position: { x: .4, y: .6 }, width: 300 },
} }));
vi.advanceTimersByTime(100);
let latest = postMessage.mock.lastCall?.[0];
expect(latest?.type).toBe("app-state");
if (latest?.type !== "app-state") throw new Error("Missing companion snapshot");
expect(latest.snapshot.game.dpsMeter).toMatchObject({ display: "companion", layout: {
visible: false, position: { x: .4, y: .6 }, width: 300,
} });
useDpsMeterStore.setState({ startedAt: 1_000, endedAt: null, updatedAt: 3_000, elapsedMs: 2_000,
damage: { test: { id: "test", name: "Test", color: "#fff", total: 200 } } }, true);
vi.advanceTimersByTime(100);
latest = postMessage.mock.lastCall?.[0];
if (latest?.type !== "app-state") throw new Error("Missing DPS snapshot");
expect(latest.snapshot.game.dpsMeter.values).toMatchObject({ totalDamage: 200, totalDps: 100, durationSeconds: 2 });
} finally { disconnect(); }
postMessage.mockClear();
useUiLayoutStore.getState().finish(false);
useDpsMeterStore.setState(emptyDpsMeter(), true);
vi.advanceTimersByTime(100);
expect(postMessage).not.toHaveBeenCalled();
});
});
+32 -1
View File
@@ -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<HudElementLayout> };
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<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width)));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: {
...layout.elements, dps: { ...layout.elements.dps, ...update },
} }));
useUiLayoutStore.setState({ selectedElement: "dps" });
break;
}
case "selectCharacter": shell.selectCharacter(command.characterId); break;
case "openCreator": shell.openCreator(); break;
case "cancelCreator": shell.cancelCreator(); break;
+56 -8
View File
@@ -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,
});
+10
View File
@@ -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)); }
+21 -8
View File
@@ -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 }
</div>
</article>
</section>
<section className="companion-map-card">
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
<div>
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
</div>
</section>
<div className="companion-tactical__overview">
<section className="companion-map-card">
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
<div>
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
</div>
</section>
{snapshot.game.dpsMeter.display === "companion" && <EditableHudElement id="dps" hostSelector=".companion-panel"
elementLayout={snapshot.game.dpsMeter.layout} editing={snapshot.game.dpsMeter.editing}
onChange={(update) => send({ name: "editDpsMeter", update })}>
<DpsMeterWidget compact snapshot={snapshot.game.dpsMeter.values} width={snapshot.game.dpsMeter.layout.width} />
</EditableHudElement>}
</div>
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
<ActionBar
items={actionItems}
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
useGameStore();
useCombatStore();
usePartyStore();
useUiLayoutStore();
useDpsMeterStore();
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
}
+14
View File
@@ -0,0 +1,14 @@
import { currentDisplayLayout } from "../platform/displayLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { resolveDpsMeterDisplay } from "../game/uiLayout";
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget";
export function DpsMeter() {
useDpsMeterStore();
const ui = useUiLayoutStore();
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
}
+20
View File
@@ -0,0 +1,20 @@
import type { CSSProperties } from "react";
import type { DpsMeterSnapshot } from "../game/dpsMeter";
import "./dpsMeter.css";
export function DpsMeterWidget({ snapshot, width, compact = false }: {
snapshot: DpsMeterSnapshot; width?: number | null; compact?: boolean;
}) {
const number = (value: number) => Math.round(value).toLocaleString();
return <section className={`dps-meter ${compact ? "dps-meter--compact" : ""}`} aria-label="DPS meter"
style={{ "--dps-meter-width": `${width ?? 230}px` } as CSSProperties}>
<header><strong>DPS</strong><small>{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"}</small>
<span>{Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}</span></header>
{snapshot.rows.length > 0 ? <ol>{snapshot.rows.map((row) => <li key={row.id}
title={`${row.name}: ${number(row.dps)} DPS · ${number(row.total)} damage`}
style={{ "--dps-color": row.color } as CSSProperties}>
<i style={{ width: `${row.percent}%` }} aria-hidden="true" /><span>{row.name}</span><b>{number(row.dps)}</b>
</li>)}</ol> : <p>Damage dealt per second</p>}
{snapshot.rows.length > 0 && <footer><span>Party · {number(snapshot.totalDamage)} damage</span><b>{number(snapshot.totalDps)} DPS</b></footer>}
</section>;
}
+20 -14
View File
@@ -1,12 +1,13 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store";
import "./uiLayout.css";
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
@@ -14,14 +15,17 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
};
/** Defaults remain in their original CSS flow until the player moves the widget. */
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, elementLayout,
editing: editingOverride, hostSelector = ".game-shell", onChange }: {
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string;
onChange?: (update: Partial<HudElementLayout>) => void;
}) {
const overlay = useGameStore((state) => state.overlay);
const editing = overlay === "ui-layout";
const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = layout.elements[id];
const element = elementLayout ?? layout.elements[id];
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null);
@@ -34,14 +38,14 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
: origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
useLayoutEffect(() => {
if (positioned || !ref.current) return;
const node = ref.current;
const measure = () => {
const child = node.firstElementChild?.getBoundingClientRect();
const host = node.closest(".game-shell")?.getBoundingClientRect();
const host = node.closest(hostSelector)?.getBoundingClientRect();
if (child && host && child.width > 0 && child.height > 0) {
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
@@ -52,21 +56,23 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
if (node.firstElementChild) observer.observe(node.firstElementChild);
window.addEventListener("resize", measure);
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
}, [positioned, children]);
}, [positioned, children, hostSelector]);
useLayoutEffect(() => {
const node = ref.current;
const host = node?.closest(".game-shell");
const host = node?.closest(hostSelector);
if (!positioned || !node || !host) return;
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect();
}, [positioned, host]);
}, [positioned, host, hostSelector]);
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
} }));
const updatePosition = (x: number, y: number) => {
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
if (onChange) onChange(update);
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
};
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
@@ -85,7 +91,7 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
}}
onPointerMove={(event) => {
if (drag.current?.pointerId !== event.pointerId) return;
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
const host = ref.current!.closest(hostSelector)!.getBoundingClientRect();
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
}}
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
+23 -8
View File
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline";
import { PartyFrames } from "./PartyFrames";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
import { TimedEffectStrip } from "./TimedEffectStrip";
import { AuraStrip } from "./AuraStrip";
import { ShelvedHud } from "@shelvedHud";
import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement";
import { DpsMeter } from "./DpsMeter";
import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames";
@@ -82,12 +85,16 @@ export function DefeatedPrompt({
resurrectionCharges,
onReturn,
sharedOnline = false,
checkpoints = [],
}: {
gameMode: "dungeon" | "manastorm";
resurrectionCharges: number;
onReturn: () => void;
onReturn: (checkpointId?: string) => void;
sharedOnline?: boolean;
checkpoints?: readonly ResurrectionCheckpoint[];
}) {
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
return (
<section
@@ -103,11 +110,16 @@ export function DefeatedPrompt({
Stay down and wait for a party member to resurrect you, or choose to return now.
</p>
{canReturn ? (
<button type="button" className="button button--primary" onClick={onReturn}>
{gameMode === "manastorm"
? `Resurrect now · ${resurrectionCharges} remaining`
: "Respawn at entrance"}
</button>
<>
{gameMode === "dungeon" && checkpoints.length > 0 ? (
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
) : null}
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
{gameMode === "manastorm"
? `Resurrect now · ${resurrectionCharges} remaining`
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
</button>
</>
) : sharedOnline ? (
<small>Your shared run is still active. A living player can resurrect you.</small>
) : (
@@ -159,6 +171,7 @@ export function Hud() {
const assetStatus = useGameStore((state) => state.assetStatus);
const assetMessage = useGameStore((state) => state.assetMessage);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const resurrectionCheckpoints = useResurrectionCheckpoints();
const dungeon = requireDungeonDefinition(activeDungeonId);
const encounter = useManastormStore((state) => state.currentEncounter);
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
@@ -349,12 +362,12 @@ export function Hud() {
mapId: dungeon.mapId,
areaName,
});
const returnFromDefeat = () => {
const returnFromDefeat = (checkpointId?: string) => {
if (gameMode === "manastorm") {
resurrectManastormSession({ kind: "player" });
return;
}
respawnDefeatedPlayer();
respawnDefeatedPlayer(checkpointId ?? "entrance");
};
const finishDungeon = () => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
@@ -364,6 +377,7 @@ export function Hud() {
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ShelvedHud />
<DpsMeter />
<BossLootWindow />
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
@@ -501,6 +515,7 @@ export function Hud() {
gameMode={gameMode}
resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat}
checkpoints={resurrectionCheckpoints}
sharedOnline={onlineSession !== null}
/>
) : null}
+29 -2
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from "react";
import { useState, type CSSProperties } from "react";
import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -230,6 +233,11 @@ export function PartyFrames() {
.map((aura) => aura.definition.name)
.join(", "));
const members = usePartyStore((state) => state.members);
const gameMode = useGameStore((state) => state.gameMode);
const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs));
const checkpoints = useResurrectionCheckpoints();
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const selfSelected = usePartyStore((state) => state.selfSelected);
const routeBlocked = usePartyStore((state) => state.routeBlocked);
@@ -321,6 +329,25 @@ export function PartyFrames() {
onSelect={() => selectMember(member.id)}
/>
))}
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
<div className="party-resurrection">
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
<button
type="button"
className="party-rez-button"
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
? "Only the party's simulation host can resurrect NPC teammates."
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
onClick={() => resurrectDeadPartyMembers(checkpointId)}
>
<strong>Rez dead members</strong>
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
? "Simulation host only" : "Then return to you"}</small>
</button>
</div>
) : null}
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useMemo, useSyncExternalStore } from "react";
import { useCombatStore } from "../game/combatStore";
import { activeDungeonCompletedObjectiveIds, activeDungeonEncounterRevision, subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { dungeonResurrectionCheckpoints, type ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
import { useGameStore } from "../game/store";
export function useResurrectionCheckpoints() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const mobs = useCombatStore((state) => state.mobs);
const revision = useSyncExternalStore(subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision, activeDungeonEncounterRevision);
return useMemo(() => dungeonResurrectionCheckpoints(requireDungeonDefinition(dungeonId), mobs,
activeDungeonCompletedObjectiveIds(dungeonId)), [dungeonId, mobs, revision]);
}
export function ResurrectionDestination({ checkpoints, value, onChange, disabled = false }: {
checkpoints: readonly ResurrectionCheckpoint[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
}) {
return (
<label className="resurrection-destination">
<span>Resurrect at</span>
<select value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled}>
{checkpoints.map((checkpoint) => <option key={checkpoint.id} value={checkpoint.id}>{checkpoint.name}</option>)}
</select>
</label>
);
}
+17
View File
@@ -138,6 +138,23 @@ export function UiLayoutEditor() {
</div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
</details>
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
}))} />Show DPS meter</label>
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
}))}>
<option value="auto">Automatic · PC lower left / Thor bottom</option>
<option value="main">Main screen</option><option value="companion">Companion screen</option>
</select></label>
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
dps: { ...layout.elements.dps, width: Number(event.target.value) },
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
</details>
{ui.device === "pc" && <details open><summary>PC action bars</summary>
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
const combat = useCombatStore.getState();
+24
View File
@@ -2,6 +2,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
import { ResurrectionDestination } from "./ResurrectionDestination";
describe("defeated prompt", () => {
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
@@ -25,6 +26,29 @@ describe("defeated prompt", () => {
expect(markup).toContain("No shared resurrection charges remain");
expect(markup).not.toContain("Resurrect now");
});
it("offers labeled entrance and killed-boss choices on the player defeat prompt", () => {
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
gameMode: "dungeon", resurrectionCharges: 0, onReturn: () => undefined,
checkpoints: [
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
],
}));
expect(markup).toContain("Resurrect at");
expect(markup).toContain("value=\"boss:bazil\"");
expect(markup).toContain("Dungeon entrance");
});
it("retains a selected boss destination in the shared chooser", () => {
const markup = renderToStaticMarkup(createElement(ResurrectionDestination, {
checkpoints: [
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
], value: "boss:bazil", onChange: () => undefined,
}));
expect(markup).toContain("value=\"boss:bazil\" selected=\"\"");
});
});
describe("dungeon completion prompt", () => {
+42
View File
@@ -0,0 +1,42 @@
.dps-meter {
position: absolute;
z-index: 12;
bottom: max(32px, env(safe-area-inset-bottom));
left: max(14px, env(safe-area-inset-left));
width: min(var(--dps-meter-width, 230px), calc(100% - 28px));
max-height: 220px;
overflow: auto;
padding: 8px;
border: 1px solid var(--edge, #536344);
border-radius: 6px;
background: rgba(5, 17, 12, .9);
color: var(--ink, #ece5d0);
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: auto;
}
.dps-meter > header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.dps-meter > header strong { color: var(--gold, #d5c28b); font: bold 13px Georgia, serif; }
.dps-meter > header small { flex: 1; color: var(--muted, #a4b69a); font-size: 10px; }
.dps-meter > header span { font-size: 10px; }
.dps-meter > p { margin: 4px 0; color: var(--muted, #a4b69a); font-size: 10px; }
.dps-meter > ol { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
.dps-meter li { position: relative; display: flex; align-items: center; gap: 8px; min-height: 20px; padding: 2px 5px; overflow: hidden; border-radius: 3px; background: #ffffff06; }
.dps-meter li > i { position: absolute; inset: 0 auto 0 0; background: var(--dps-color); opacity: .3; }
.dps-meter li > span { position: relative; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dps-meter li > b { position: relative; font-size: 10px; }
.dps-meter > footer { display: flex; justify-content: space-between; gap: 5px; margin-top: 6px; color: var(--muted, #a4b69a); font-size: 9px; }
.dps-meter > footer b { color: var(--ink, #ece5d0); white-space: nowrap; }
.editable-hud--editing:has(> .dps-meter) { padding-top: 24px; }
.dps-meter--compact { position: relative; inset: auto; width: min(var(--dps-meter-width, 230px), 100%); max-height: 150px; padding: 5px; font-size: 8px; }
.dps-meter--compact > header { gap: 5px; margin-bottom: 3px; }
.dps-meter--compact > header strong { font-size: 10px; }
.dps-meter--compact > header :is(small, span), .dps-meter--compact > p { font-size: 7px; }
.dps-meter--compact > ol { gap: 2px; }
.dps-meter--compact li { min-height: 13px; padding: 1px 4px; }
.dps-meter--compact li > b { font-size: 7px; }
.dps-meter--compact > footer { margin-top: 3px; font-size: 6px; }
.companion-tactical__overview { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-width: 0; min-height: 0; }
.companion-tactical__overview > .companion-map-card { grid-template-columns: minmax(0, 1fr) 110px; min-height: 0; }
.ui-layout-editor__dps { padding: 5px 0; }
.ui-layout-editor__dps input[type="range"] { flex: 1; min-width: 0; }
+87
View File
@@ -0,0 +1,87 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCombatStore } from "../game/combatStore";
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../game/dungeonEncounterRuntime";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
import { PartyFrames } from "./PartyFrames";
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
vi.mock("../game/partyStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../game/partyStore")>();
return { ...actual, usePartyStore: Object.assign(
(select: (state: ReturnType<typeof actual.usePartyStore.getState>) => unknown) => select(actual.usePartyStore.getState()), actual.usePartyStore,
) };
});
vi.mock("../game/combatStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../game/combatStore")>();
return { ...actual, useCombatStore: Object.assign(
(select: (state: ReturnType<typeof actual.useCombatStore.getState>) => unknown) => select(actual.useCombatStore.getState()), actual.useCombatStore,
) };
});
vi.mock("../game/store", async (importOriginal) => {
const actual = await importOriginal<typeof import("../game/store")>();
return { ...actual, useGameStore: Object.assign(
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
) };
});
const dungeon = requireDungeonDefinition("stormwind-stockade");
const render = () => renderToStaticMarkup(createElement(PartyFrames));
function killMember() {
const member = usePartyStore.getState().members[0];
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
}
describe("party resurrection UI", () => {
beforeEach(() => {
useGameStore.getState().activateDungeon("stormwind-stockade");
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
resetDungeonEncounters("dungeon", dungeon.id);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
useCombatStore.setState({ mobs: {} });
usePartyStore.getState().initializeParty("rez-ui", 16, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
});
it("hides the control when all NPCs are alive, even if only the player is dead", () => {
expect(render()).not.toContain("Rez dead members");
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
expect(render()).not.toContain("Rez dead members");
});
it("places the button and labeled destination selector below every party frame", () => {
killMember();
const markup = render();
expect(markup).toContain("Rez dead members");
expect(markup).toContain("Resurrect at");
expect(markup).toContain("Dungeon entrance");
expect(markup.indexOf("party-resurrection")).toBeGreaterThan(markup.lastIndexOf("party-frame__identity"));
expect(markup).not.toMatch(/class="party-rez-button" disabled/);
});
it("shows why resurrection is disabled during a living boss encounter", () => {
killMember();
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, maxHealth: 100 });
useCombatStore.getState().damageMob("boss", 1, 100);
expect(render()).toMatch(/class="party-rez-button" disabled=""/);
expect(render()).toContain("Unavailable during boss combat");
});
it("offers killed boss destinations but not bosses that are still alive", () => {
killMember();
const boss = dungeon.bosses[0];
expect(render()).not.toContain(`value="boss:${boss.id}"`);
useCombatStore.getState().registerMob(boss.id, { name: boss.name, boss: true, maxHealth: 1 });
useCombatStore.getState().damageMob(boss.id, 1, 100);
expect(render()).toContain(`value="boss:${boss.id}"`);
});
it("does not offer free resurrection in Manastorm", () => {
killMember();
useGameStore.setState({ gameMode: "manastorm" });
expect(render()).not.toContain("Rez dead members");
});
});