Release Healer Man 0.1.37
This commit is contained in:
1 parent
03a4e3f51c
commit
361734bc41
41 files changed
+1714
-110
No files matched your search
+62
-57
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel } from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
function killPlayer(): void {
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
||||
}
|
||||
|
||||
function selfHot(): TimedCombatEffect {
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
||||
expect(effect).toBeDefined();
|
||||
return effect!;
|
||||
}
|
||||
|
||||
function setMemberHealth(id: string, health: number): void {
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
||||
}
|
||||
|
||||
describe("healing must not implicitly resurrect defeated players", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
||||
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
||||
usePartyStore.getState().selectMember(null);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
});
|
||||
|
||||
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
||||
.toMatchObject({ ok: false, reason: "player-dead" });
|
||||
});
|
||||
|
||||
it("removes a dead player's HoT even before its next tick is due", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
||||
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
||||
&& ability.effects.some((effect) => effect.kind === "hot"));
|
||||
expect(renew).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
||||
const state = useCombatStore.getState();
|
||||
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
||||
killPlayer();
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects companion HoTs with an explicit player target after death", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
||||
const effect = selfHot();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects newly applied teammate HoTs on dead targets", () => {
|
||||
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
||||
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
||||
expect(hot.kind).toBe("hot");
|
||||
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const source = usePartyStore.getState().members[0].id;
|
||||
killPlayer();
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
||||
const member = usePartyStore.getState().members[1];
|
||||
setMemberHealth(member.id, 0);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a fallen ally's HoT without changing other party members", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
setMemberHealth(member.id, 0);
|
||||
const others = usePartyStore.getState().members.slice(1);
|
||||
useCombatStore.getState().tick(0, effect.nextTickAt);
|
||||
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
||||
const effect = selfHot();
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
useCombatStore.setState({ effects: [], areaEffects: [{
|
||||
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
||||
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
||||
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, 4_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("a HoT on a living ally continues even when its player caster dies", () => {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
setMemberHealth(member.id, 1);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
||||
const effect = useCombatStore.getState().effects[0];
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000, definition: {
|
||||
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
||||
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
||||
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
||||
},
|
||||
}).auras;
|
||||
useCombatStore.setState({ auras: [...aura], effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
||||
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
||||
setMobPosition("dot-target", [2, 0, 0]);
|
||||
useCombatStore.setState({ effects: [{
|
||||
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
||||
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
||||
}] });
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
});
|
||||
|
||||
it("living self HoTs and regeneration still restore health", () => {
|
||||
useCombatStore.getState().damagePlayer(50);
|
||||
const effect = selfHot();
|
||||
const before = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
||||
});
|
||||
|
||||
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
||||
killPlayer();
|
||||
expect(respawnDefeatedPlayer()).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().effects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
||||
const effect = selfHot();
|
||||
killPlayer();
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
||||
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
||||
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
||||
expect(restored).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
||||
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { 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;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage } from "./dpsMeter";
|
||||
import type { StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
const actor = { id: "player", name: "You", color: "#fff" };
|
||||
function damage(id: number, effectiveAmount = 10): StructuredCombatEvent {
|
||||
return { id, occurredAt: 1_000 + id * 10, kind: "damage", sourceActorId: actor.id,
|
||||
targetActorId: "enemy", rawAmount: 100, effectiveAmount, overheal: 0, absorbed: 30, critical: false };
|
||||
}
|
||||
|
||||
describe("DPS fight recorder", () => {
|
||||
it("keeps effective damage from more than 200 events and ranks everyone over the same fight duration", () => {
|
||||
let state = advanceDpsMeter(emptyDpsMeter(), 1_000, true);
|
||||
for (let id = 1; id <= 1_000; id++) state = recordDpsDamage(state, damage(id), actor);
|
||||
const ally = { id: "ally", name: "Ally", color: "#f00" };
|
||||
state = recordDpsDamage(state, { ...damage(1_000), effectiveAmount: 5_000 }, ally);
|
||||
const snapshot = dpsMeterSnapshot(state, [{ id: "healer", name: "Healer", color: "#0f0" }]);
|
||||
expect(snapshot).toMatchObject({ totalDamage: 15_000, totalDps: 1_500, durationSeconds: 10 });
|
||||
expect(snapshot.rows.map((row) => [row.id, row.dps, row.percent])).toEqual([
|
||||
["player", 1_000, 100], ["ally", 500, 50], ["healer", 0, 0],
|
||||
]);
|
||||
});
|
||||
it("freezes the last fight, starts fresh on the next pull, and excludes menu time", () => {
|
||||
let state = recordDpsDamage(emptyDpsMeter(), damage(0, 100), actor);
|
||||
state = advanceDpsMeter(state, 2_000, true);
|
||||
state = advanceDpsMeter(state, 12_000, true, true);
|
||||
state = advanceDpsMeter(state, 13_000, false);
|
||||
expect(dpsMeterSnapshot(state)).toMatchObject({ active: false, durationSeconds: 2, totalDps: 50 });
|
||||
expect(advanceDpsMeter(state, 30_000, false)).toBe(state);
|
||||
state = recordDpsDamage(state, { ...damage(0, 20), occurredAt: 31_000 }, actor);
|
||||
expect(dpsMeterSnapshot(state)).toMatchObject({ active: true, durationSeconds: 0, totalDamage: 20, totalDps: 20 });
|
||||
});
|
||||
it("ignores misses, healing, invalid damage, and clock regressions", () => {
|
||||
const empty = emptyDpsMeter();
|
||||
expect(recordDpsDamage(empty, damage(0, 0), actor)).toBe(empty);
|
||||
expect(recordDpsDamage(empty, damage(0, NaN), actor)).toBe(empty);
|
||||
expect(recordDpsDamage(empty, { ...damage(0), kind: "healing" }, actor)).toBe(empty);
|
||||
let state = recordDpsDamage(empty, damage(0), actor);
|
||||
state = advanceDpsMeter(state, 900, true);
|
||||
expect(state.elapsedMs).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
export interface DpsMeterActor {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export interface DpsMeterState {
|
||||
readonly startedAt: number | null;
|
||||
readonly endedAt: number | null;
|
||||
readonly updatedAt: number | null;
|
||||
readonly elapsedMs: number;
|
||||
readonly damage: Readonly<Record<string, DpsMeterActor & { total: number }>>;
|
||||
}
|
||||
|
||||
export interface DpsMeterRow extends DpsMeterActor {
|
||||
readonly total: number;
|
||||
readonly dps: number;
|
||||
readonly percent: number;
|
||||
}
|
||||
|
||||
export interface DpsMeterSnapshot {
|
||||
readonly active: boolean;
|
||||
readonly durationSeconds: number;
|
||||
readonly totalDamage: number;
|
||||
readonly totalDps: number;
|
||||
readonly rows: readonly DpsMeterRow[];
|
||||
}
|
||||
|
||||
export function emptyDpsMeter(): DpsMeterState {
|
||||
return { startedAt: null, endedAt: null, updatedAt: null, elapsedMs: 0, damage: {} };
|
||||
}
|
||||
|
||||
/** Keep the last pull visible until the next starts; menus do not add combat time. */
|
||||
export function advanceDpsMeter(state: DpsMeterState, now: number, inCombat: boolean, suspended = false): DpsMeterState {
|
||||
if (!Number.isFinite(now)) return state;
|
||||
if (state.startedAt === null || state.endedAt !== null) {
|
||||
return inCombat ? { ...emptyDpsMeter(), startedAt: now, updatedAt: now } : state;
|
||||
}
|
||||
const updatedAt = Math.max(state.updatedAt ?? now, now);
|
||||
return { ...state, updatedAt,
|
||||
elapsedMs: state.elapsedMs + (suspended ? 0 : updatedAt - (state.updatedAt ?? updatedAt)),
|
||||
endedAt: inCombat ? null : updatedAt };
|
||||
}
|
||||
|
||||
/** Consume the lossless stream, never the truncated combat-log history. */
|
||||
export function recordDpsDamage(state: DpsMeterState, event: StructuredCombatEvent, actor: DpsMeterActor): DpsMeterState {
|
||||
if (event.kind !== "damage" || !Number.isFinite(event.effectiveAmount) || event.effectiveAmount <= 0) return state;
|
||||
const next = advanceDpsMeter(state, event.occurredAt, true);
|
||||
return { ...next, damage: { ...next.damage,
|
||||
[actor.id]: { ...actor, total: (next.damage[actor.id]?.total ?? 0) + event.effectiveAmount } } };
|
||||
}
|
||||
|
||||
export function dpsMeterSnapshot(state: DpsMeterState, actors: readonly DpsMeterActor[] = []): DpsMeterSnapshot {
|
||||
const durationSeconds = state.elapsedMs / 1_000;
|
||||
const seconds = Math.max(1, durationSeconds);
|
||||
const damage = state.startedAt === null ? {} : {
|
||||
...Object.fromEntries(actors.map((actor) => [actor.id, { ...actor, total: 0 }])), ...state.damage,
|
||||
};
|
||||
const ordered = Object.values(damage).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||
const maximum = Math.max(1, ordered[0]?.total ?? 0);
|
||||
const totalDamage = ordered.reduce((sum, actor) => sum + actor.total, 0);
|
||||
return { active: state.startedAt !== null && state.endedAt === null, durationSeconds, totalDamage,
|
||||
totalDps: totalDamage / seconds,
|
||||
rows: ordered.map((actor) => ({ ...actor, dps: actor.total / seconds, percent: actor.total / maximum * 100 })) };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { appendCombatEvent } from "./combatEvents";
|
||||
import { connectDpsMeter, currentDpsMeterSnapshot, useDpsMeterStore } from "./dpsMeterStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useUiLayoutStore } from "./uiLayoutStore";
|
||||
import { currentCompanionSnapshot, executeCompanionCommand } from "../platform/dualScreenSync";
|
||||
|
||||
describe("live DPS tracking and companion editor", () => {
|
||||
let disconnect: () => void;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(1_000);
|
||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 5 });
|
||||
useCombatStore.getState().registerMob("dps-target", { name: "Target", maxHealth: 1_000_000 });
|
||||
disconnect = connectDpsMeter();
|
||||
});
|
||||
afterEach(() => { disconnect(); useUiLayoutStore.getState().finish(false); vi.useRealTimers(); });
|
||||
|
||||
it("tracks real damage while hidden, caps overkill, freezes on defeat, and clears on reset", () => {
|
||||
useUiLayoutStore.getState().begin();
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: false } } }));
|
||||
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ totalDamage: 100, totalDps: 50 });
|
||||
const remaining = useCombatStore.getState().mobs["dps-target"].health;
|
||||
useCombatStore.getState().damageMob("dps-target", 2_000_000, Date.now());
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ active: false, totalDamage: 100 + remaining });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(currentDpsMeterSnapshot().durationSeconds).toBe(2);
|
||||
useCombatStore.getState().resetEncounter();
|
||||
expect(useDpsMeterStore.getState().startedAt).toBeNull();
|
||||
});
|
||||
it("merges summon damage into its owner and excludes hostile and friendly-target damage", () => {
|
||||
const actors = useCombatStore.getState().actors;
|
||||
useCombatStore.setState({ actors: { ...actors, "meter-pet": { ...actors[PLAYER_AGGRO_ID], id: "meter-pet", kind: "pet", ownerId: PLAYER_AGGRO_ID } } });
|
||||
for (const [id, sourceActorId, targetActorId] of [[1, "meter-pet", "dps-target"], [2, "dps-target", PLAYER_AGGRO_ID], [3, PLAYER_AGGRO_ID, PLAYER_AGGRO_ID]] as const) {
|
||||
appendCombatEvent([], { occurredAt: Date.now(), kind: "damage", sourceActorId, targetActorId,
|
||||
rawAmount: 100, effectiveAmount: 80, overheal: 0, absorbed: 20, critical: false }, id);
|
||||
}
|
||||
expect(currentDpsMeterSnapshot().totalDamage).toBe(80);
|
||||
expect(currentDpsMeterSnapshot().rows.find((row) => row.total > 0)?.id).toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
it("does not dilute DPS while a local game menu pauses combat", () => {
|
||||
useCombatStore.getState().damageMob("dps-target", 100, Date.now());
|
||||
vi.advanceTimersByTime(1_000);
|
||||
useGameStore.setState({ paused: true, overlay: "ui-layout" });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 1, totalDps: 100 });
|
||||
useGameStore.setState({ paused: false, overlay: null });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 2, totalDps: 50 });
|
||||
});
|
||||
it("publishes draft visibility and accepts lower-screen movement only while editing", () => {
|
||||
useUiLayoutStore.getState().begin();
|
||||
const command = { name: "editDpsMeter", update: { position: { x: .7, y: .8 }, width: 300 } } as const;
|
||||
executeCompanionCommand(command);
|
||||
expect(useUiLayoutStore.getState().draft?.elements.dps.position).toBeNull();
|
||||
useGameStore.setState({ overlay: "ui-layout" });
|
||||
executeCompanionCommand(command);
|
||||
expect(currentCompanionSnapshot().game.dpsMeter).toMatchObject({ editing: true, layout: { position: { x: .7, y: .8 }, width: 300 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { create } from "zustand";
|
||||
import { classById, CLASSES } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter";
|
||||
import { onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export const useDpsMeterStore = create(() => emptyDpsMeter());
|
||||
|
||||
function meterActors(): DpsMeterActor[] {
|
||||
const combat = useCombatStore.getState();
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
return [
|
||||
{ id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color },
|
||||
...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })),
|
||||
...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({
|
||||
id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username,
|
||||
color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b",
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function dpsSourceActor(sourceId: string): DpsMeterActor | null {
|
||||
const combat = useCombatStore.getState();
|
||||
const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId;
|
||||
const visited = new Set<string>();
|
||||
let id = sourceId;
|
||||
while (!visited.has(id)) {
|
||||
visited.add(id);
|
||||
if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID;
|
||||
const known = meterActors().find((actor) => actor.id === id);
|
||||
if (known) return known;
|
||||
const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
|
||||
if (!ownerId) return null;
|
||||
id = ownerId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function currentDpsMeterSnapshot() {
|
||||
return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors());
|
||||
}
|
||||
|
||||
/** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */
|
||||
export function connectDpsMeter(): () => void {
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
let suspendedPreviously = false;
|
||||
const sample = () => {
|
||||
const combat = useCombatStore.getState();
|
||||
const game = useGameStore.getState();
|
||||
const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
|
||||
const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot);
|
||||
const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden));
|
||||
const state = useDpsMeterStore.getState();
|
||||
const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously);
|
||||
suspendedPreviously = suspended;
|
||||
if (next !== state) useDpsMeterStore.setState(next, true);
|
||||
};
|
||||
const unsubscribeEvents = subscribeCombatEvents((event) => {
|
||||
if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return;
|
||||
const actor = dpsSourceActor(event.sourceActorId);
|
||||
if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true);
|
||||
});
|
||||
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||
if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) {
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
} else sample();
|
||||
});
|
||||
const unsubscribeGame = useGameStore.subscribe(sample);
|
||||
if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample);
|
||||
const timer = setInterval(sample, 250);
|
||||
sample();
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame();
|
||||
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample);
|
||||
useDpsMeterStore.setState(emptyDpsMeter(), true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { partyRejoinMovement } from "./partyRejoin";
|
||||
import { emptyPartyRouteState } from "./partyPathing";
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
const graph: PartyNavigationGraph = {
|
||||
nodes: [
|
||||
{ id: 0, position: [0, 0, 0], neighbors: [{ to: 1, cost: 6 }] },
|
||||
{ id: 1, position: [0, 0, 6], neighbors: [{ to: 0, cost: 6 }, { to: 2, cost: 2 }] },
|
||||
{ id: 2, position: [2, 0, 6], neighbors: [{ to: 1, cost: 2 }, { to: 3, cost: 6 }] },
|
||||
{ id: 3, position: [2, 0, 0], neighbors: [{ to: 2, cost: 6 }] },
|
||||
],
|
||||
weldTolerance: .001, sourceVertexCount: 4, sourceTriangleCount: 2,
|
||||
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||
};
|
||||
|
||||
describe("individual resurrection return navigation", () => {
|
||||
it("finishes only when near the player on a clear traversable segment", () => {
|
||||
expect(partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, null, emptyPartyRouteState(),
|
||||
{ directSegmentAllowed: () => true }).rejoined).toBe(true);
|
||||
expect(partyRejoinMovement("tank", [0, 0, 0], [0, 5, 0], 0, null, emptyPartyRouteState(),
|
||||
{ directSegmentAllowed: () => true }).rejoined).toBe(false);
|
||||
});
|
||||
|
||||
it("routes out of a room even when the player is close on the other side of a wall", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [2, 0, 0], 0, graph, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: (start, end) => start[0] === end[0] || (start[2] >= 5 && end[2] >= 5),
|
||||
routeSegmentAllowed: () => true,
|
||||
});
|
||||
expect(result.rejoined).toBe(false);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.waypoint).toEqual([0, 0, 6]);
|
||||
});
|
||||
|
||||
it("follows the current player position, not a fixed death location or boss", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: () => true,
|
||||
});
|
||||
const moved = partyRejoinMovement("tank", [4, 0, 0], [20, 0, 8], 50, null, result.route, {
|
||||
directSegmentAllowed: () => true,
|
||||
});
|
||||
expect(moved.waypoint).toEqual([20, 0, 8]);
|
||||
expect(moved.rejoined).toBe(false);
|
||||
});
|
||||
|
||||
it("reports blocked navigation to the existing watchdog instead of walking through walls", () => {
|
||||
const result = partyRejoinMovement("tank", [0, 0, 0], [20, 0, 0], 0, null, emptyPartyRouteState(), {
|
||||
directSegmentAllowed: () => false,
|
||||
});
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.waypoint).toBeNull();
|
||||
expect(result.rejoined).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "./partyNavigation";
|
||||
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteOptions, type PartyRouteState } from "./partyPathing";
|
||||
import type { PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
export const PARTY_REJOIN_DISTANCE = 3;
|
||||
|
||||
/** Returning resurrected members target the player, not a distant boss or leader. */
|
||||
export function partyRejoinMovement(
|
||||
id: string, current: PartyWorldPosition, player: PartyWorldPosition, nowMs: number,
|
||||
graph: PartyNavigationGraph | null, route: PartyRouteState,
|
||||
options: Pick<PartyRouteOptions, "directSegmentAllowed" | "routeSegmentAllowed">,
|
||||
) {
|
||||
const reachedPlayer = (point: PartyWorldPosition) => Math.hypot(
|
||||
point[0] - player[0], point[1] - player[1], point[2] - player[2],
|
||||
) <= PARTY_REJOIN_DISTANCE && Boolean(options.directSegmentAllowed?.(point, player));
|
||||
if (reachedPlayer(current)) return { rejoined: true, route: emptyPartyRouteState(), waypoint: null, blocked: false };
|
||||
if (options.directSegmentAllowed?.(current, player)) {
|
||||
return { rejoined: false, route: emptyPartyRouteState(), waypoint: player, blocked: false };
|
||||
}
|
||||
const destination = graph ? findNearestPartyNavigationSurface(graph, player, 5)?.position ?? player : player;
|
||||
const goalKey = `member:${id}|resurrection-rejoin`;
|
||||
if (partyRouteNeedsPlanning(route, goalKey, current, destination, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||
route = planPartyRoute(graph, goalKey, current, destination, nowMs, {
|
||||
...options, maxOriginSnapDistance: 6, maxDestinationSnapDistance: 5,
|
||||
localFinishDistance: PARTY_REJOIN_DISTANCE,
|
||||
destinationReached: reachedPlayer,
|
||||
});
|
||||
}
|
||||
const waypoint: [number, number, number] = [0, 0, 0];
|
||||
const hasWaypoint = nextPartyRouteWaypoint(route, current, waypoint)
|
||||
|| nextPartyRouteRecoveryWaypoint(route, current, waypoint, options.directSegmentAllowed ?? (() => false));
|
||||
return { rejoined: false, route, waypoint: hasWaypoint ? waypoint : null, blocked: route.blocked && !hasWaypoint };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { configureOnlineSessionRuntime } from "./onlineSessionRuntime";
|
||||
import { clearPartyRuntimeRegistry, consumePartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { resurrectDeadPartyMembers } from "./partyResurrection";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
|
||||
function killMember(index: number) {
|
||||
const member = usePartyStore.getState().members[index];
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||
return member.id;
|
||||
}
|
||||
|
||||
describe("fallen NPC checkpoint resurrection", () => {
|
||||
beforeEach(() => {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
clearPartyRuntimeRegistry();
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
usePartyStore.getState().initializeParty("resurrection-party", 16, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [99, 0, 99]);
|
||||
});
|
||||
|
||||
it("restores only fallen members at the original entrance, not the current saved spawn", () => {
|
||||
const deadId = killMember(0);
|
||||
usePartyStore.getState().setCommand("stop");
|
||||
const living = usePartyStore.getState().members.slice(1);
|
||||
const livingActors = living.map((member) => useCombatStore.getState().actors[member.id]);
|
||||
const gameBefore = useGameStore.getState();
|
||||
const playerBefore = useCombatStore.getState().health;
|
||||
useGameStore.setState({ activeSpawn: { footPosition: [80, 5, 80], yaw: 1 } });
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ health: usePartyStore.getState().members[0].maxHealth, status: "advancing" });
|
||||
expect(getPartyRuntimePosition(deadId)).toEqual(derivePartySpawnSlots(dungeon.entrance, 5)[1].footPosition);
|
||||
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||
expect(consumePartyRuntimeRejoin(deadId)).not.toBeNull();
|
||||
expect(consumePartyRuntimeRejoin(deadId)).toBeNull();
|
||||
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
|
||||
expect(usePartyStore.getState().members.slice(1)).toEqual(living);
|
||||
expect(living.map((member) => useCombatStore.getState().actors[member.id])).toEqual(livingActors);
|
||||
expect(usePartyStore.getState().command).toBe("stop");
|
||||
expect(useCombatStore.getState().health).toBe(playerBefore);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(gameBefore.playerPosition);
|
||||
expect(useGameStore.getState().resetRevision).toBe(gameBefore.resetRevision);
|
||||
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ kind: "resurrection", targetActorId: deadId });
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(0);
|
||||
});
|
||||
|
||||
it("restores all fallen members at a killed boss without resetting any living encounter", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
const mobsBefore = useCombatStore.getState().mobs;
|
||||
const ids = usePartyStore.getState().members.map((_, index) => killMember(index));
|
||||
expect(resurrectDeadPartyMembers(`boss:${boss.id}`, 1_000)).toBe(ids.length);
|
||||
const slots = derivePartySpawnSlots({ footPosition: boss.position, yaw: dungeon.entrance.yaw }, ids.length + 1);
|
||||
ids.forEach((id, index) => expect(getPartyRuntimePosition(id)).toEqual(slots[index + 1].footPosition));
|
||||
expect(useCombatStore.getState().mobs).toBe(mobsBefore);
|
||||
});
|
||||
|
||||
it("enforces boss combat at action time and remains usable during trash combat", () => {
|
||||
const id = killMember(0);
|
||||
useCombatStore.getState().registerMob("active-boss", { name: "Boss", maxHealth: 100, boss: true });
|
||||
useCombatStore.getState().damageMob("active-boss", 1, 100);
|
||||
const before = useCombatStore.getState();
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(0);
|
||||
expect(useCombatStore.getState()).toBe(before);
|
||||
expect(partyRuntimeIsRejoining(id)).toBe(false);
|
||||
useCombatStore.getState().damageMob("active-boss", 99, 101);
|
||||
useCombatStore.getState().registerMob("trash", { name: "Trash", maxHealth: 100 });
|
||||
useCombatStore.getState().damageMob("trash", 1, 102);
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects locked, stale, or arbitrary checkpoint IDs without any mutation", () => {
|
||||
killMember(0);
|
||||
const before = useCombatStore.getState();
|
||||
for (const id of [`boss:${dungeon.bosses[0].id}`, "boss:other-dungeon", "20,30,40"]) {
|
||||
expect(resurrectDeadPartyMembers(id, 1_000)).toBe(0);
|
||||
expect(useCombatStore.getState()).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot spend or bypass Manastorm revival charges or run on a non-authority client", () => {
|
||||
killMember(0);
|
||||
useGameStore.setState({ gameMode: "manastorm" });
|
||||
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||
useGameStore.setState({ gameMode: "dungeon" });
|
||||
configureOnlineSessionRuntime({ active: true, authority: false, localAccountId: "guest" });
|
||||
expect(resurrectDeadPartyMembers()).toBe(0);
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
expect(resurrectDeadPartyMembers("entrance", NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not resurrect the dead player with the NPC button", () => {
|
||||
killMember(0);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().combatEvents.at(-1)?.targetActorId).not.toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
|
||||
it("removes lingering effects only on the resurrected member, leaving living allies' HoTs intact", () => {
|
||||
const members = usePartyStore.getState().members;
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
|
||||
usePartyStore.getState().selectMember(members[0].id);
|
||||
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
usePartyStore.getState().selectMember(members[1].id);
|
||||
expect(useCombatStore.getState().castAbility(renew.id, undefined, 5_000).ok).toBe(true);
|
||||
const livingEffects = useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id);
|
||||
expect(livingEffects.length).toBeGreaterThan(0);
|
||||
killMember(0);
|
||||
expect(resurrectDeadPartyMembers("entrance", 6_000)).toBe(1);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === members[0].id)).toBe(false);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id)).toEqual(livingEffects);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||
import { beginPartyRuntimeRejoin } from "./partyRuntimeRegistry";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
|
||||
|
||||
export function partyResurrectionBlockedByBoss(mobs: Readonly<Record<string, MobCombatState>>): boolean {
|
||||
return Object.values(mobs).some((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned);
|
||||
}
|
||||
|
||||
/** Normal-dungeon recovery only; never bypass Manastorm charges or online authority. */
|
||||
export function resurrectDeadPartyMembers(checkpointId = "entrance", now = Date.now()): number {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
if (game.gameMode !== "dungeon" || !Number.isFinite(now) || !onlineSessionOwnsWorldSimulation()
|
||||
|| partyResurrectionBlockedByBoss(combat.mobs)) return 0;
|
||||
const checkpoint = resolveResurrectionCheckpoint(checkpointId);
|
||||
if (!checkpoint) return 0;
|
||||
const party = usePartyStore.getState();
|
||||
const fallen = party.members.filter((member) => (combat.actors[member.id]?.health ?? member.health) <= 0);
|
||||
if (!fallen.length) return 0;
|
||||
const ids = new Set(fallen.map((member) => member.id));
|
||||
const slots = derivePartySpawnSlots(checkpoint.spawn, party.members.length + 1);
|
||||
useCombatStore.setState({ effects: combat.effects.filter((effect) => !effect.targetId || !ids.has(effect.targetId)) });
|
||||
let restoredCount = 0;
|
||||
for (const member of fallen) {
|
||||
if (useCombatStore.getState().reviveActor(member.id, 1, PLAYER_AGGRO_ID, "party-checkpoint-resurrection", now) <= 0) continue;
|
||||
const index = party.members.findIndex((candidate) => candidate.id === member.id);
|
||||
beginPartyRuntimeRejoin(member.id, (slots[index + 1] ?? slots[0]).footPosition);
|
||||
usePartyStore.getState().updateMemberCombat(member.id, {
|
||||
status: "advancing", activeCast: null, activeCastTargetId: null, controlledUntil: 0, controlMechanic: null,
|
||||
});
|
||||
restoredCount += 1;
|
||||
}
|
||||
return restoredCount;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
registerPartyRuntimePosition,
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { availableResurrectionCheckpoints, dungeonResurrectionCheckpoints } from "./resurrectionCheckpoints";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
|
||||
describe("boss resurrection checkpoints", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
});
|
||||
|
||||
it("always offers the entrance and unlocks every killed boss, including optional bosses", () => {
|
||||
expect(dungeonResurrectionCheckpoints(dungeon, {})).toHaveLength(1);
|
||||
const result = dungeonResurrectionCheckpoints(dungeon, {}, dungeon.bosses.map((boss) => boss.id));
|
||||
expect(result.map((checkpoint) => checkpoint.id)).toEqual(["entrance", ...dungeon.bosses.map((boss) => `boss:${boss.id}`)]);
|
||||
dungeon.bosses.forEach((boss, index) => expect(result[index + 1].spawn.footPosition).toEqual(boss.position));
|
||||
});
|
||||
|
||||
it("uses durable encounter completion after hydration when no corpse is registered", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: boss.id, now: 100 });
|
||||
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 100, facts: { deadEntityIds: [boss.id], registeredEntityIds: [boss.id] } });
|
||||
expect(activeDungeonCompletedObjectiveIds(dungeon.id)).toContain(boss.id);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
resetDungeonEncounters("boss-wipe", dungeon.id);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not award a checkpoint for an alive or script-despawned boss", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 100, boss: true });
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
const mob = useCombatStore.getState().mobs[boss.id];
|
||||
useCombatStore.setState({ mobs: { [boss.id]: { ...mob, dead: true, health: 0, despawned: true } } });
|
||||
expect(availableResurrectionCheckpoints()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the defeated player and party reset signal to the selected boss, preserving the kill", () => {
|
||||
const boss = dungeon.bosses[0];
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
const before = useGameStore.getState().resetRevision;
|
||||
expect(respawnDefeatedPlayer(`boss:${boss.id}`)).toBe(true);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(boss.position);
|
||||
expect(useGameStore.getState().resetRevision).toBe(before + 1);
|
||||
expect(useCombatStore.getState().playerPosition).toEqual(boss.position);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||
expect(availableResurrectionCheckpoints().map((checkpoint) => checkpoint.id)).toContain(`boss:${boss.id}`);
|
||||
});
|
||||
|
||||
it("lets a defeated player explicitly choose the entrance instead of a previously saved boss spawn", () => {
|
||||
useGameStore.setState({ activeSpawn: { footPosition: dungeon.bosses[0].position, yaw: 1 } });
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(respawnDefeatedPlayer("entrance")).toBe(true);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(dungeon.entrance.footPosition);
|
||||
});
|
||||
|
||||
it("rejects locked boss choices and cannot teleport a living player", () => {
|
||||
const before = useGameStore.getState();
|
||||
expect(respawnDefeatedPlayer("entrance")).toBe(false);
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useGameStore.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import type { DungeonDefinition, WorldSpawn } from "./dungeonTypes";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export interface ResurrectionCheckpoint {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly spawn: WorldSpawn;
|
||||
}
|
||||
|
||||
/** Authored boss positions only: never accept a corpse position or arbitrary UI coordinates. */
|
||||
export function dungeonResurrectionCheckpoints(
|
||||
dungeon: DungeonDefinition,
|
||||
mobs: Readonly<Record<string, MobCombatState>>,
|
||||
completedObjectiveIds: readonly string[] = [],
|
||||
): ResurrectionCheckpoint[] {
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
return [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: dungeon.entrance },
|
||||
...dungeon.bosses.filter((boss) => completed.has(boss.id)
|
||||
|| (mobs[boss.id]?.dead && !mobs[boss.id]?.despawned)).map((boss) => ({
|
||||
id: `boss:${boss.id}`, name: boss.name,
|
||||
spawn: { footPosition: boss.position, yaw: dungeon.entrance.yaw },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function availableResurrectionCheckpoints(): ResurrectionCheckpoint[] {
|
||||
const game = useGameStore.getState();
|
||||
return dungeonResurrectionCheckpoints(requireDungeonDefinition(game.activeDungeonId),
|
||||
useCombatStore.getState().mobs, activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||
}
|
||||
|
||||
export function resolveResurrectionCheckpoint(id: string): ResurrectionCheckpoint | null {
|
||||
if (useGameStore.getState().gameMode !== "dungeon") return null;
|
||||
return availableResurrectionCheckpoints().find((checkpoint) => checkpoint.id === id) ?? null;
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from "./partyNavigation";
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user