Release Healer Man 0.1.35
This commit is contained in:
1 parent
46c272f889
commit
40d9f346d8
13 files changed
+218
-118
No files matched your search
@@ -38,7 +38,6 @@ import {
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partySeparationGoal,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
personalizedPartyCombatRangeBand,
|
||||
@@ -84,12 +83,12 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import {
|
||||
createPartyDirectSegmentValidator,
|
||||
createPartyRouteSegmentValidator,
|
||||
PARTY_FLOOR_RAY_DEPTH,
|
||||
PARTY_FLOOR_RAY_HEIGHT,
|
||||
partyTerrainQueryFlags,
|
||||
} from "./partyTraversal";
|
||||
|
||||
const PARTY_MOVE_SPEED = 5.25;
|
||||
@@ -300,7 +299,8 @@ function PartyActors({
|
||||
ray,
|
||||
PARTY_FLOOR_RAY_DEPTH,
|
||||
true,
|
||||
partyTerrainQueryFlags(rapier),
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
|
||||
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
|
||||
@@ -394,7 +394,8 @@ function PartyActors({
|
||||
ray,
|
||||
20,
|
||||
true,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
|
||||
},
|
||||
@@ -636,11 +637,9 @@ function PartyActors({
|
||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const livingIndices = party.members
|
||||
.map((member, index) => member.health > 0 ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
|
||||
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
|
||||
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
|
||||
const key = `${memberId}|${livingRank}`;
|
||||
@@ -651,13 +650,6 @@ function PartyActors({
|
||||
}
|
||||
return profile;
|
||||
};
|
||||
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
|
||||
const peers: PartyWorldPosition[] = [];
|
||||
for (const [id, position] of positionsRef.current) {
|
||||
if (id !== memberId && livingIds.has(id)) peers.push(position);
|
||||
}
|
||||
return peers;
|
||||
};
|
||||
let leaderHasWaypoint = false;
|
||||
let leaderMovementExpected = false;
|
||||
let leaderFollowingPlayer = false;
|
||||
@@ -712,15 +704,12 @@ function PartyActors({
|
||||
};
|
||||
combatAnchorsRef.current.set(leader.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(leader.id);
|
||||
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
leaderPosition,
|
||||
targetPosition,
|
||||
hasStaticLineOfSight(leaderPosition, targetPosition),
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
leaderDestination,
|
||||
);
|
||||
if (movementIntent === "hold") {
|
||||
@@ -1039,15 +1028,12 @@ function PartyActors({
|
||||
};
|
||||
combatAnchorsRef.current.set(member.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(member.id);
|
||||
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
current,
|
||||
memberTargetPosition,
|
||||
canSeeActiveTarget,
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
desired,
|
||||
);
|
||||
movementExpected = movementIntent !== "hold";
|
||||
@@ -1245,23 +1231,6 @@ function PartyActors({
|
||||
}
|
||||
|
||||
const movementProfile = movementProfileFor(member.id, index);
|
||||
if (hasDesired) {
|
||||
const peers = peerPositionsFor(member.id);
|
||||
if (
|
||||
partySeparationGoal(
|
||||
current,
|
||||
desired,
|
||||
peers,
|
||||
movementProfile.swayPhase,
|
||||
separatedDesired,
|
||||
)
|
||||
&& directSegmentAllowed(current, separatedDesired)
|
||||
) {
|
||||
desired[0] = separatedDesired[0];
|
||||
desired[1] = separatedDesired[1];
|
||||
desired[2] = separatedDesired[2];
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
||||
|
||||
+13
-7
@@ -60,6 +60,7 @@ import {
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
|
||||
const PLAYER_RADIUS = 0.36;
|
||||
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||
@@ -212,8 +213,8 @@ function PlayerController({
|
||||
groundRay,
|
||||
groundProbeLength,
|
||||
true,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
@@ -267,7 +268,8 @@ function PlayerController({
|
||||
y: verticalVelocity * movementDeltaSeconds,
|
||||
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
|
||||
},
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
const computedMovement = characterController.computedMovement();
|
||||
const nextTranslation = {
|
||||
@@ -347,7 +349,11 @@ function PlayerController({
|
||||
spawn.footPosition[2],
|
||||
]}
|
||||
>
|
||||
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} />
|
||||
<CapsuleCollider
|
||||
args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]}
|
||||
friction={0}
|
||||
collisionGroups={ACTOR_COLLISION_GROUPS}
|
||||
/>
|
||||
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
|
||||
<CharacterModel
|
||||
identity={identity}
|
||||
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
|
||||
|
||||
// Cast a small sphere instead of a single ray. This behaves like a camera
|
||||
// spring arm and keeps the whole near-camera volume out of walls, pillars,
|
||||
// and low ceilings. The player's own capsule is explicitly ignored.
|
||||
// and low ceilings. All actor capsules are ignored, including the player.
|
||||
const hit = world.castShape(
|
||||
target,
|
||||
shapeRotation,
|
||||
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
|
||||
CAMERA_WALL_GAP,
|
||||
fullDistance,
|
||||
false,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement";
|
||||
import { isWalkableGroundHit } from "../game/playerJump";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
|
||||
|
||||
const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const;
|
||||
type BodyType = typeof BODY_TYPES[number];
|
||||
|
||||
beforeAll(async () => {
|
||||
await RAPIER.init();
|
||||
});
|
||||
|
||||
function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) {
|
||||
const description = bodyType === "fixed"
|
||||
? RAPIER.RigidBodyDesc.fixed()
|
||||
: bodyType === "kinematic"
|
||||
? RAPIER.RigidBodyDesc.kinematicPositionBased()
|
||||
: RAPIER.RigidBodyDesc.dynamic();
|
||||
const body = world.createRigidBody(description.setTranslation(0, 0.84, z));
|
||||
const collider = world.createCollider(
|
||||
RAPIER.ColliderDesc.capsule(0.48, 0.36)
|
||||
.setCollisionGroups(groups)
|
||||
.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
|
||||
body,
|
||||
);
|
||||
return { body, collider };
|
||||
}
|
||||
|
||||
function terrain(world: RAPIER.World, wall = false) {
|
||||
const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8)
|
||||
.setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1)
|
||||
.setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
|
||||
return floor;
|
||||
}
|
||||
|
||||
describe("non-blocking combat actors", () => {
|
||||
it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => {
|
||||
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
const player = actor(world, "dynamic");
|
||||
// Party members, mobs, bosses, and remote players use the same actor policy.
|
||||
const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType));
|
||||
world.step();
|
||||
for (const other of others) {
|
||||
world.contactPair(player.collider, other.collider, () => {
|
||||
throw new Error("Actors must not produce physical contacts");
|
||||
});
|
||||
expect(other.body.translation().z).toBeCloseTo(0);
|
||||
}
|
||||
expect(player.body.translation().z).toBeCloseTo(0);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("detects contacts when an actor mistakenly uses the terrain layer", () => {
|
||||
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
const player = actor(world, "dynamic");
|
||||
const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS);
|
||||
world.step();
|
||||
let contacts = 0;
|
||||
world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); });
|
||||
expect(contacts).toBeGreaterThan(0);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => {
|
||||
for (const hasWall of [false, true]) {
|
||||
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
terrain(world, hasWall);
|
||||
actor(world, bodyType);
|
||||
actor(world, bodyType, -2); // Start overlapped: no depenetration or detour.
|
||||
const player = actor(world, "kinematic", -2);
|
||||
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
|
||||
world.step();
|
||||
for (let frame = 0; frame < 90; frame += 1) {
|
||||
controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 },
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
|
||||
const position = player.body.translation();
|
||||
const movement = controller.computedMovement();
|
||||
player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z });
|
||||
world.step();
|
||||
}
|
||||
const position = player.body.translation();
|
||||
if (hasWall) {
|
||||
expect(position.z).toBeGreaterThan(1.8);
|
||||
expect(position.z).toBeLessThan(2.1);
|
||||
} else expect(position.z).toBeGreaterThan(5);
|
||||
expect(position.x).toBeCloseTo(0);
|
||||
expect(position.y).toBeCloseTo(0.84, 1);
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => {
|
||||
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
|
||||
const floor = terrain(world);
|
||||
actor(world, bodyType);
|
||||
world.step();
|
||||
const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }),
|
||||
5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
|
||||
expect(ground?.collider.handle).toBe(floor.handle);
|
||||
expect(ground?.timeOfImpact).toBeCloseTo(4);
|
||||
expect(isWalkableGroundHit(ground, 5)).toBe(true);
|
||||
expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
|
||||
expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
|
||||
expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 },
|
||||
{ x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false,
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull();
|
||||
world.free();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { interactionGroups } from "@react-three/rapier";
|
||||
|
||||
export const TERRAIN_COLLISION_GROUP = 0;
|
||||
export const ACTOR_COLLISION_GROUP = 1;
|
||||
|
||||
// Players, companions, mobs, and bosses never block or push one another.
|
||||
// Any actor capsule added in future must use this layer, not the terrain layer.
|
||||
export const ACTOR_COLLISION_GROUPS = interactionGroups(
|
||||
ACTOR_COLLISION_GROUP,
|
||||
TERRAIN_COLLISION_GROUP,
|
||||
);
|
||||
|
||||
// Movement, ground probes, and camera sweeps see only physical terrain,
|
||||
// regardless of whether an actor's body is fixed, kinematic, or dynamic.
|
||||
export const ACTOR_TERRAIN_QUERY_GROUPS = ACTOR_COLLISION_GROUPS;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { interactionGroups } from "@react-three/rapier";
|
||||
import { TERRAIN_COLLISION_GROUP } from "./actorCollision";
|
||||
import type {
|
||||
QueryFilterFlags,
|
||||
Ray,
|
||||
@@ -17,9 +18,9 @@ interface RapierApi {
|
||||
type WorldPosition = readonly [number, number, number];
|
||||
|
||||
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
|
||||
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0);
|
||||
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(TERRAIN_COLLISION_GROUP);
|
||||
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
|
||||
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]);
|
||||
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([TERRAIN_COLLISION_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP]);
|
||||
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
|
||||
DUNGEON_LINE_OF_SIGHT_GROUP,
|
||||
DUNGEON_LINE_OF_SIGHT_GROUP,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type RAPIER from "@dimforge/rapier3d-compat";
|
||||
import type { World } from "@dimforge/rapier3d-compat";
|
||||
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
|
||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
|
||||
const COLLISION_RADIUS = 0.34;
|
||||
const COLLISION_HALF_HEIGHT = 0.48;
|
||||
@@ -11,14 +12,6 @@ export const PARTY_FLOOR_RAY_DEPTH = 2;
|
||||
const FLOOR_SAMPLE_SPACING = 0.6;
|
||||
const MAX_FLOOR_DELTA = 0.55;
|
||||
|
||||
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
|
||||
// Actor capsules are not terrain. Crowd spacing is handled separately by
|
||||
// party separation; counting the player here blocks recalls and replans.
|
||||
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
|
||||
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
|
||||
}
|
||||
|
||||
/** Shared by live party routing and collision-backed traversal regressions. */
|
||||
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const capsule = new rapier.Capsule(
|
||||
@@ -26,7 +19,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
COLLISION_RADIUS,
|
||||
);
|
||||
const rotation = { x: 0, y: 0, z: 0, w: 1 };
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
@@ -43,6 +36,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
distance,
|
||||
false,
|
||||
flags,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
|
||||
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
|
||||
@@ -54,7 +48,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
|
||||
z: start[2] + dz * progress,
|
||||
}, { x: 0, y: -1, z: 0 });
|
||||
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags);
|
||||
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
|
||||
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -63,7 +57,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
|
||||
|
||||
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
|
||||
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
@@ -74,7 +68,7 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
|
||||
const ray = new rapier.Ray({
|
||||
x: start[0], y: start[1] + 0.9, z: start[2],
|
||||
}, { x: dx / distance, y: dy / distance, z: dz / distance });
|
||||
const hit = world.castRay(ray, distance, true, flags);
|
||||
const hit = world.castRay(ray, distance, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
|
||||
return !hit || hit.timeOfImpact >= distance - 0.04;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user