Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,433 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
abilityAtLevel as resolveAbilityAtLevel,
|
||||
} from "./abilityCatalog";
|
||||
import { useCombatStore, stableCombatUnit } from "./combatStore";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { creatureTemplateReference } from "./azerothCoreReference";
|
||||
import { clearMobRuntimeRegistry, registerMobRuntime, type CombatPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
|
||||
const SOURCE_ENTRY = 1666;
|
||||
const PLAYER_TARGET = "spell-block-player-source-npc";
|
||||
const PARTY_TARGET = "spell-block-party-source-npc";
|
||||
const TEST_PARTY_ID = "sourced-spell-block-test-party";
|
||||
const SYNTHETIC_CRIT_AURA = "integration-test:guaranteed-critical";
|
||||
const FRONT_POSITION: CombatPosition = [0, 0, -2];
|
||||
const BACK_POSITION: CombatPosition = [0, 0, 2];
|
||||
const PARTY_FRONT_POSITION: CombatPosition = [0, 0, -10];
|
||||
|
||||
const spatialActors = new Map<string, { readonly position: CombatPosition; readonly yaw: number }>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["warrior", "hunter", "mage"]);
|
||||
});
|
||||
|
||||
function setSpatialActors(
|
||||
entries: Readonly<Record<string, CombatPosition>>,
|
||||
yaws: Readonly<Record<string, number>> = {},
|
||||
): void {
|
||||
spatialActors.clear();
|
||||
for (const [id, position] of Object.entries(entries)) spatialActors.set(id, { position, yaw: yaws[id] ?? 0 });
|
||||
registerCombatSpatialProvider({
|
||||
actor: (id) => {
|
||||
const actor = spatialActors.get(id);
|
||||
return actor ? { id, ...actor } : null;
|
||||
},
|
||||
hasLineOfSight: () => true,
|
||||
projectGroundPoint: (position) => position,
|
||||
});
|
||||
}
|
||||
|
||||
function registerSourceNpc(id: string): void {
|
||||
const template = creatureTemplateReference(SOURCE_ENTRY);
|
||||
if (!template) throw new Error(`Missing AzerothCore CreatureTemplate ${SOURCE_ENTRY}.`);
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: template.name,
|
||||
serverEntry: SOURCE_ENTRY,
|
||||
level: 30,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
registerMobRuntime(id, [0, 0, 0], Math.PI);
|
||||
spatialActors.set(id, { position: [0, 0, 0], yaw: Math.PI });
|
||||
}
|
||||
|
||||
function applyGuaranteedCriticalAura(actorId: string, now: number): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: SYNTHETIC_CRIT_AURA,
|
||||
name: "Synthetic Integration Critical",
|
||||
description: "Test-only fixture that makes one real spell cast critically hit.",
|
||||
disposition: "buff",
|
||||
durationMs: 60_000,
|
||||
utilityTags: ["next-offensive-critical"],
|
||||
},
|
||||
sourceId: actorId,
|
||||
targetId: actorId,
|
||||
now,
|
||||
}).auras],
|
||||
}));
|
||||
}
|
||||
|
||||
function preparePlayer(
|
||||
classId: "warrior" | "hunter" | "mage",
|
||||
targetId: string,
|
||||
position = FRONT_POSITION,
|
||||
playerYaw = 0,
|
||||
): void {
|
||||
useCombatStore.getState().initializeCharacter({ classId, level: 30 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
TEST_PARTY_ID, 30, useCombatStore.getState().health, "damage", 1, "wow",
|
||||
);
|
||||
useCombatStore.getState().setPlayerPosition(position);
|
||||
registerSourceNpc(targetId);
|
||||
setSpatialActors({ [PLAYER_AGGRO_ID]: position, [targetId]: [0, 0, 0] }, {
|
||||
[PLAYER_AGGRO_ID]: playerYaw,
|
||||
[targetId]: Math.PI,
|
||||
});
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: state.maxResource,
|
||||
resourcePools: { ...state.resourcePools, [state.resourceName === "Rage" ? "rage" : "mana"]: state.maxResource },
|
||||
}));
|
||||
}
|
||||
|
||||
function nextCriticalBlockedTime(
|
||||
sourceId: string,
|
||||
abilityId: string,
|
||||
targetId: string,
|
||||
seedKind: "direct" | "special-crit",
|
||||
firstTime = 1_000,
|
||||
): number {
|
||||
for (let now = firstTime; now < 200_000; now += 1) {
|
||||
const seed = seedKind === "direct"
|
||||
? `${sourceId}:${abilityId}:${targetId}:${now}:direct`
|
||||
: `${sourceId}:${abilityId}:${targetId}:${now}:special-crit`;
|
||||
const outcome = stableCombatUnit(`${seed}:outcome`);
|
||||
if (stableCombatUnit(`${seed}:block`) < 0.05 && outcome > 0.2 && outcome < 0.8) return now;
|
||||
}
|
||||
throw new Error("Could not find a deterministic crit-and-block source-spell roll.");
|
||||
}
|
||||
|
||||
function nextPlayerCriticalTime(abilityId: string, targetId: string): number {
|
||||
for (let now = 1_000; now < 200_000; now += 1) {
|
||||
const seed = `${PLAYER_AGGRO_ID}:${abilityId}:${targetId}:${now}:direct`;
|
||||
const outcome = stableCombatUnit(`${seed}:outcome`);
|
||||
if (outcome > 0.2 && outcome < 0.8) return now;
|
||||
}
|
||||
throw new Error("Could not find a deterministic landed critical spell roll.");
|
||||
}
|
||||
|
||||
function playerDamageEvent(targetId: string, abilityId: string) {
|
||||
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
||||
candidate.kind === "damage"
|
||||
&& candidate.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& candidate.targetActorId === targetId
|
||||
&& candidate.abilityId === abilityId
|
||||
));
|
||||
if (!event) throw new Error(`Missing player damage event for ${abilityId} against ${targetId}.`);
|
||||
return event;
|
||||
}
|
||||
|
||||
function partyDamageEvent(targetId: string, actorId: string, abilityId: string) {
|
||||
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
||||
candidate.kind === "damage"
|
||||
&& candidate.sourceActorId === actorId
|
||||
&& candidate.targetActorId === targetId
|
||||
&& candidate.abilityId === abilityId
|
||||
));
|
||||
if (!event) throw new Error(`Missing companion damage event for ${abilityId} against ${targetId}.`);
|
||||
return event;
|
||||
}
|
||||
|
||||
function completePlayerCastIfNeeded(now: number): void {
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
if (activeCast) useCombatStore.getState().tick(0.1, Math.max(now, activeCast.completesAt));
|
||||
}
|
||||
|
||||
function setSelectedMobActiveCast(targetId: string, now: number): void {
|
||||
const target = useCombatStore.getState().mobs[targetId];
|
||||
if (!target) throw new Error(`Missing NPC target ${targetId}.`);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[targetId]: {
|
||||
...state.mobs[targetId]!,
|
||||
activeCast: {
|
||||
attackId: "synthetic-block-gate-cast",
|
||||
name: "Synthetic Block Gate Cast",
|
||||
school: "fire",
|
||||
interruptible: true,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
sourcePosition: [0, 0, 0] as const,
|
||||
targetPosition: FRONT_POSITION,
|
||||
sourceYaw: 0,
|
||||
startedAt: now - 1,
|
||||
completesAt: now + 5_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function abilityFor(classId: "warrior" | "hunter" | "mage", name: string) {
|
||||
const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name);
|
||||
if (!ability) throw new Error(`Missing ${classId} ability ${name}.`);
|
||||
return ability;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
spatialActors.clear();
|
||||
});
|
||||
|
||||
describe("sourced direct-spell blocks against ordinary source NPCs", () => {
|
||||
it("blocks a player's sourced physical melee spell after the hit, including a simultaneous critical hit", () => {
|
||||
preparePlayer("warrior", PLAYER_TARGET);
|
||||
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
||||
const resolvedHeroicStrike = resolveAbilityAtLevel(heroicStrike, 30);
|
||||
expect(resolvedHeroicStrike.wowSpellDefense).toMatchObject({
|
||||
damageClass: 2,
|
||||
schoolMask: 1,
|
||||
attributes0: 327700,
|
||||
attributes3: 1024,
|
||||
directDamageFromEffects: true,
|
||||
sourceProfile: "wow335",
|
||||
});
|
||||
const template = creatureTemplateReference(SOURCE_ENTRY)!;
|
||||
expect(template.flagsExtra).toBeDefined();
|
||||
expect(template.flagsExtra! & 0x10).toBe(0);
|
||||
|
||||
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
|
||||
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
||||
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
completePlayerCastIfNeeded(now);
|
||||
|
||||
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
|
||||
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
||||
landed: true,
|
||||
outcome: "crit",
|
||||
critical: true,
|
||||
blocked: true,
|
||||
blockedAmount: 15,
|
||||
postHitBlockChancePercent: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks a real generated WoW hunter companion's sourced physical ranged spell", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
TEST_PARTY_ID, 50, useCombatStore.getState().health, "damage", 1, "wow",
|
||||
);
|
||||
const steadyShotBase = abilityFor("hunter", "Steady Shot");
|
||||
const steadyShot = resolveAbilityAtLevel(steadyShotBase, 50);
|
||||
expect(steadyShot.wowSpellDefense).toMatchObject({
|
||||
damageClass: 3,
|
||||
schoolMask: 1,
|
||||
attributes0: 4259858,
|
||||
attributes3: 0,
|
||||
directDamageFromEffects: true,
|
||||
sourceProfile: "wow335",
|
||||
});
|
||||
const memberSeed = createConfiguredPartyMember({
|
||||
name: "Sourced Spell Block Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Marksmanship",
|
||||
specializationId: "hunter:marksmanship",
|
||||
talentTreeId: "hunter-marksmanship",
|
||||
talentRanks: {},
|
||||
role: "damage",
|
||||
}, 50, 0, TEST_PARTY_ID);
|
||||
const learnedAbilities = abilitiesForClass("hunter")
|
||||
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
|
||||
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
|
||||
const disabledCooldowns = Object.fromEntries(learnedAbilities
|
||||
.filter((ability) => ability.id !== steadyShot.id)
|
||||
.map((ability) => [ability.id, 100_000]));
|
||||
const member = {
|
||||
...memberSeed,
|
||||
cooldowns: disabledCooldowns,
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready" as const,
|
||||
resource: memberSeed.maxResource,
|
||||
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
|
||||
};
|
||||
usePartyStore.setState({ members: [member], command: "attack", activeMobId: PARTY_TARGET });
|
||||
registerSourceNpc(PARTY_TARGET);
|
||||
// Synthetic engagement makes the companion AI enter its normal hostile-target rotation.
|
||||
useCombatStore.getState().damageMob(PARTY_TARGET, 1, 900, {
|
||||
actorId: PLAYER_AGGRO_ID,
|
||||
role: "damage",
|
||||
});
|
||||
registerPartyRuntimePosition(member.id, PARTY_FRONT_POSITION);
|
||||
updatePartyRuntimeTargetVisibility(member.id, PARTY_TARGET, true);
|
||||
setSpatialActors({ [member.id]: PARTY_FRONT_POSITION, [PARTY_TARGET]: [0, 0, 0] }, {
|
||||
[PARTY_TARGET]: Math.PI,
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const startAt = nextCriticalBlockedTime(
|
||||
member.id,
|
||||
steadyShot.id,
|
||||
PARTY_TARGET,
|
||||
"special-crit",
|
||||
steadyShot.castTimeMs + 1_000,
|
||||
)
|
||||
- steadyShot.castTimeMs;
|
||||
applyGuaranteedCriticalAura(member.id, startAt - 1);
|
||||
advanceDungeonPartyCombat(startAt);
|
||||
const activeCast = usePartyStore.getState().members[0]?.activeCast;
|
||||
expect(activeCast).toMatchObject({ abilityId: steadyShot.id, targetId: PARTY_TARGET });
|
||||
expect(activeCast?.completesAt).toBe(startAt + steadyShot.castTimeMs);
|
||||
advanceDungeonPartyCombat(activeCast!.completesAt);
|
||||
|
||||
const event = partyDamageEvent(PARTY_TARGET, member.id, steadyShot.id);
|
||||
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
||||
outcome: "crit",
|
||||
critical: true,
|
||||
blocked: true,
|
||||
blockedAmount: 15,
|
||||
postHitBlockChancePercent: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source magic spells outside physical NPC blocking", () => {
|
||||
preparePlayer("mage", PLAYER_TARGET, [0, 0, -10]);
|
||||
const fireball = resolveAbilityAtLevel(abilityFor("mage", "Fireball"), 30);
|
||||
expect(fireball.wowSpellDefense).toMatchObject({ damageClass: 1, schoolMask: 4, directDamageFromEffects: true });
|
||||
expect(creatureTemplateReference(SOURCE_ENTRY)!.schoolImmunities).not.toContain("fire");
|
||||
const fireballAt = nextPlayerCriticalTime(fireball.id, PLAYER_TARGET);
|
||||
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, fireballAt - 1);
|
||||
const fireballCast = useCombatStore.getState().castAbility(fireball.id, {
|
||||
now: fireballAt,
|
||||
targetActorId: PLAYER_TARGET,
|
||||
});
|
||||
expect(fireballCast, JSON.stringify(fireballCast)).toMatchObject({ ok: true });
|
||||
completePlayerCastIfNeeded(fireballAt);
|
||||
|
||||
const fireballEvent = playerDamageEvent(PLAYER_TARGET, fireball.id);
|
||||
expect(fireballEvent).toMatchObject({ school: "fire", critical: true, outcome: "crit", blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
||||
|
||||
preparePlayer("hunter", PLAYER_TARGET, [0, 0, -10]);
|
||||
const arcaneShot = resolveAbilityAtLevel(abilityFor("hunter", "Arcane Shot"), 30);
|
||||
expect(arcaneShot.wowSpellDefense).toMatchObject({ damageClass: 3, schoolMask: 64, directDamageFromEffects: true });
|
||||
const arcaneShotAt = nextPlayerCriticalTime(arcaneShot.id, PLAYER_TARGET);
|
||||
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, arcaneShotAt - 1);
|
||||
const arcaneShotCast = useCombatStore.getState().castAbility(arcaneShot.id, {
|
||||
now: arcaneShotAt,
|
||||
targetActorId: PLAYER_TARGET,
|
||||
});
|
||||
expect(arcaneShotCast, JSON.stringify(arcaneShotCast)).toMatchObject({ ok: true });
|
||||
completePlayerCastIfNeeded(arcaneShotAt);
|
||||
|
||||
const arcaneShotEvent = playerDamageEvent(PLAYER_TARGET, arcaneShot.id);
|
||||
expect(arcaneShotEvent).toMatchObject({ critical: true, outcome: "crit", blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the actual selected DBC rank and fails closed for unknown rank IDs", () => {
|
||||
preparePlayer("warrior", PLAYER_TARGET);
|
||||
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
||||
const higherRank = heroicStrike.ranks?.at(-1);
|
||||
expect(higherRank).toBeDefined();
|
||||
expect(higherRank?.spellId).not.toBe(heroicStrike.dbcSpellId);
|
||||
const validSeed = [...Array(20_000)].map((_, index) => `higher-rank:${index}`)
|
||||
.find((seed) => stableCombatUnit(`${seed}:block`) < 0.05);
|
||||
if (!validSeed) throw new Error("Could not find deterministic higher-rank block seed.");
|
||||
const validRank = useCombatStore.getState().damageMob(
|
||||
PLAYER_TARGET,
|
||||
1_000,
|
||||
50_000,
|
||||
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
|
||||
"physical",
|
||||
{
|
||||
attackKind: "melee",
|
||||
abilityId: heroicStrike.id,
|
||||
dbcSpellId: higherRank!.spellId,
|
||||
seed: validSeed,
|
||||
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
|
||||
},
|
||||
);
|
||||
expect(validRank.combatResult).toMatchObject({ blocked: true, blockedAmount: 15, postHitBlockChancePercent: 5 });
|
||||
|
||||
const unknown = useCombatStore.getState().damageMob(
|
||||
PLAYER_TARGET,
|
||||
1_000,
|
||||
50_001,
|
||||
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
|
||||
"physical",
|
||||
{
|
||||
attackKind: "melee",
|
||||
abilityId: heroicStrike.id,
|
||||
dbcSpellId: 9_999_999,
|
||||
seed: "unknown-selected-rank",
|
||||
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
|
||||
},
|
||||
);
|
||||
expect(unknown.combatResult?.blocked).toBe(false);
|
||||
expect(unknown.combatResult?.postHitBlockChancePercent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not allow an attacker behind the source NPC to enter its spell block roll", () => {
|
||||
preparePlayer("warrior", PLAYER_TARGET, BACK_POSITION, Math.PI);
|
||||
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
||||
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
|
||||
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
||||
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
completePlayerCastIfNeeded(now);
|
||||
|
||||
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
|
||||
expect(event).toMatchObject({ critical: true, blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not allow a source NPC actively casting or controlled to block the physical spell", () => {
|
||||
for (const gate of ["casting", "controlled"] as const) {
|
||||
const targetId = `${PLAYER_TARGET}-${gate}`;
|
||||
preparePlayer("warrior", targetId);
|
||||
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
||||
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, targetId, "direct");
|
||||
if (gate === "casting") {
|
||||
setSelectedMobActiveCast(targetId, now);
|
||||
} else {
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[targetId]: {
|
||||
...state.mobs[targetId]!,
|
||||
statuses: [{ kind: "stun", sourceAbilityId: "synthetic-spell-block-gate", endsAt: now + 5_000 }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
||||
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: targetId });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
completePlayerCastIfNeeded(now);
|
||||
|
||||
const event = playerDamageEvent(targetId, heroicStrike.id);
|
||||
expect(event).toMatchObject({ critical: true, blocked: false });
|
||||
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user