Basic AI defending
This commit is contained in:
@@ -3,6 +3,7 @@ package mindustry.ai;
|
|||||||
import arc.*;
|
import arc.*;
|
||||||
import arc.graphics.g2d.*;
|
import arc.graphics.g2d.*;
|
||||||
import arc.math.*;
|
import arc.math.*;
|
||||||
|
import arc.math.geom.*;
|
||||||
import arc.struct.*;
|
import arc.struct.*;
|
||||||
import arc.util.*;
|
import arc.util.*;
|
||||||
import mindustry.*;
|
import mindustry.*;
|
||||||
@@ -12,11 +13,17 @@ import mindustry.game.Teams.*;
|
|||||||
import mindustry.gen.*;
|
import mindustry.gen.*;
|
||||||
import mindustry.graphics.*;
|
import mindustry.graphics.*;
|
||||||
import mindustry.ui.*;
|
import mindustry.ui.*;
|
||||||
|
import mindustry.world.*;
|
||||||
import mindustry.world.blocks.defense.turrets.Turret.*;
|
import mindustry.world.blocks.defense.turrets.Turret.*;
|
||||||
|
import mindustry.world.blocks.storage.CoreBlock.*;
|
||||||
import mindustry.world.meta.*;
|
import mindustry.world.meta.*;
|
||||||
|
|
||||||
public class RtsAI{
|
public class RtsAI{
|
||||||
static final Seq<Building> targets = new Seq<>();
|
static final Seq<Building> targets = new Seq<>();
|
||||||
|
static final Seq<Unit> squad = new Seq<>(false);
|
||||||
|
static final IntSet used = new IntSet();
|
||||||
|
static final IntSet assignedTargets = new IntSet();
|
||||||
|
static final float squadRadius = 120f;
|
||||||
static final int timeUpdate = 0;
|
static final int timeUpdate = 0;
|
||||||
static final float minWeight = 0.9f;
|
static final float minWeight = 0.9f;
|
||||||
|
|
||||||
@@ -24,73 +31,181 @@ public class RtsAI{
|
|||||||
static final BlockFlag[] flags = {BlockFlag.generator, BlockFlag.factory, BlockFlag.core, BlockFlag.battery};
|
static final BlockFlag[] flags = {BlockFlag.generator, BlockFlag.factory, BlockFlag.core, BlockFlag.battery};
|
||||||
static final ObjectFloatMap<Building> weights = new ObjectFloatMap<>();
|
static final ObjectFloatMap<Building> weights = new ObjectFloatMap<>();
|
||||||
static final int minSquadSize = 4;
|
static final int minSquadSize = 4;
|
||||||
static boolean debug = true;
|
//TODO max squad size
|
||||||
|
static final boolean debug = true;
|
||||||
|
|
||||||
final Interval timer = new Interval(10);
|
final Interval timer = new Interval(10);
|
||||||
final TeamData data;
|
final TeamData data;
|
||||||
|
final ObjectSet<Building> damagedSet = new ObjectSet<>();
|
||||||
|
final Seq<Building> damaged = new Seq<>(false);
|
||||||
|
|
||||||
|
//must be static, as this class can get instantiated many times; event listeners are hard to clean up
|
||||||
|
static{
|
||||||
|
Events.on(BuildDamageEvent.class, e -> {
|
||||||
|
if(e.build.team.rules().rtsAi){
|
||||||
|
var ai = e.build.team.data().rtsAi;
|
||||||
|
if(ai != null){
|
||||||
|
ai.damagedSet.add(e.build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public RtsAI(TeamData data){
|
public RtsAI(TeamData data){
|
||||||
this.data = data;
|
this.data = data;
|
||||||
|
timer.reset(0, Mathf.random(60f * 2f));
|
||||||
|
|
||||||
//TODO remove: debugging!
|
//TODO remove: debugging!
|
||||||
|
|
||||||
Events.run(Trigger.draw, () -> {
|
if(debug){
|
||||||
if(!debug) return;
|
Events.run(Trigger.draw, () -> {
|
||||||
|
|
||||||
Draw.draw(Layer.overlayUI, () -> {
|
Draw.draw(Layer.overlayUI, () -> {
|
||||||
|
|
||||||
float s = Fonts.outline.getScaleX();
|
float s = Fonts.outline.getScaleX();
|
||||||
Fonts.outline.getData().setScale(0.5f);
|
Fonts.outline.getData().setScale(0.5f);
|
||||||
for(var target : targets){
|
for(var target : weights){
|
||||||
if(weights.containsKey(target)){
|
Fonts.outline.draw("[sky]" + Strings.fixed(target.value, 2), target.key.x, target.key.y, Align.center);
|
||||||
float w = weights.get(target, 0f);
|
|
||||||
|
|
||||||
Fonts.outline.draw("[sky]" + Strings.fixed(w, 2), target.x, target.y, Align.center);
|
|
||||||
}
|
}
|
||||||
}
|
Fonts.outline.getData().setScale(s);
|
||||||
Fonts.outline.getData().setScale(s);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void update(){
|
public void update(){
|
||||||
if(timer.get(timeUpdate, 60f * 2f)){
|
if(timer.get(timeUpdate, 60f * 2f)){
|
||||||
var build = findAttackPoint();
|
assignSquads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(build != null){
|
void assignSquads(){
|
||||||
for(var unit : data.units){
|
assignedTargets.clear();
|
||||||
if(unit.isCommandable() && !unit.command().hasCommand()){
|
used.clear();
|
||||||
unit.command().commandTarget(build);
|
damaged.addAll(damagedSet);
|
||||||
|
damagedSet.clear();
|
||||||
|
|
||||||
|
boolean didDefend = false;
|
||||||
|
|
||||||
|
for(var unit : data.units){
|
||||||
|
if(unit.isCommandable() && !unit.command().hasCommand() && used.add(unit.id)){
|
||||||
|
squad.clear();
|
||||||
|
data.tree().intersect(unit.x - squadRadius/2f, unit.y - squadRadius/2f, squadRadius, squadRadius, squad);
|
||||||
|
//remove overlapping squads
|
||||||
|
squad.removeAll(u -> (u != unit && used.contains(u.id)) || !u.isCommandable() || u.command().hasCommand());
|
||||||
|
//mark used so other squads can't steal them
|
||||||
|
for(var item : squad){
|
||||||
|
used.add(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO flawed, squads
|
||||||
|
if(handleSquad(squad, !didDefend)){
|
||||||
|
didDefend = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
damaged.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean handleSquad(Seq<Unit> units, boolean noDefenders){
|
||||||
|
float health = 0f, dps = 0f;
|
||||||
|
float ax = 0f, ay = 0f;
|
||||||
|
|
||||||
|
for(var unit : units){
|
||||||
|
ax += unit.x;
|
||||||
|
ay += unit.y;
|
||||||
|
health += unit.health;
|
||||||
|
dps += unit.type.dpsEstimate;
|
||||||
|
}
|
||||||
|
ax /= units.size;
|
||||||
|
ay /= units.size;
|
||||||
|
|
||||||
|
if(debug){
|
||||||
|
Vars.ui.showLabel("Squad: " + units.size, 2f, ax, ay);
|
||||||
|
}
|
||||||
|
|
||||||
|
Building defend = null;
|
||||||
|
|
||||||
|
//there is something to defend, see if it's worth the time
|
||||||
|
if(damaged.size > 0){
|
||||||
|
//TODO do the weights matter at all?
|
||||||
|
//for(var build : damaged){
|
||||||
|
//float w = estimateStats(ax, ay, dps, health);
|
||||||
|
//weights.put(build, w);
|
||||||
|
//}
|
||||||
|
|
||||||
|
//screw you java
|
||||||
|
float aax = ax, aay = ay;
|
||||||
|
|
||||||
|
Building best = damaged.min(b -> {
|
||||||
|
//rush to core IMMEDIATELY
|
||||||
|
if(b instanceof CoreBuild){
|
||||||
|
return -999999f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.dst(aax, aay);
|
||||||
|
});
|
||||||
|
|
||||||
|
//defend when close, or this is the only squad defending
|
||||||
|
if(best instanceof CoreBuild || (units.size >= minSquadSize && (noDefenders || best.within(ax, ay, 400f)))){
|
||||||
|
defend = best;
|
||||||
|
|
||||||
|
if(debug){
|
||||||
|
Vars.ui.showLabel("Defend, dst = " + (int)(best.dst(ax, ay)), 8f, best.x, best.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//find aggressor, or else, the thing being attacked
|
||||||
|
Vec2 defendPos = null;
|
||||||
|
Teamc defendTarget = null;
|
||||||
|
if(defend != null){
|
||||||
|
//TODO could be made faster by storing bullet shooter
|
||||||
|
Unit aggressor = Units.closestEnemy(data.team, defend.x, defend.y, 250f, u -> true);
|
||||||
|
if(aggressor != null){
|
||||||
|
defendTarget = aggressor;
|
||||||
|
}else if(false){ //TODO currently ignored, no use defending against nothing
|
||||||
|
//should it even go there if there's no aggressor found?
|
||||||
|
Tile closest = defend.findClosestEdge(units.first(), Tile::solid);
|
||||||
|
if(closest != null){
|
||||||
|
defendPos = new Vec2(closest.worldx(), closest.worldy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean anyDefend = defendPos != null || defendTarget != null;
|
||||||
|
|
||||||
|
var build = anyDefend ? null : findTarget(ax, ay, units.size, dps, health);
|
||||||
|
|
||||||
|
if(build != null || anyDefend){
|
||||||
|
for(var unit : units){
|
||||||
|
if(unit.isCommandable() && !unit.command().hasCommand()){
|
||||||
|
if(defendPos != null){
|
||||||
|
unit.command().commandPosition(defendPos);
|
||||||
|
}else{
|
||||||
|
unit.command().commandTarget(defendTarget == null ? build : defendTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return anyDefend;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable Building findAttackPoint(){
|
@Nullable Building findTarget(float x, float y, int total, float dps, float health){
|
||||||
float health = 0f, dps = 0f;
|
if(total < minSquadSize) return null;
|
||||||
int total = 0;
|
|
||||||
for(var unit : data.units){
|
|
||||||
if(unit.isCommandable() && !unit.command().hasCommand()){
|
|
||||||
health += unit.health;
|
|
||||||
dps += unit.type.dpsEstimate;
|
|
||||||
total ++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//flag priority?
|
//flag priority?
|
||||||
//1. generator
|
//1. generator
|
||||||
//2. factor
|
//2. factory
|
||||||
//3. core
|
//3. core
|
||||||
|
|
||||||
//TODO split into "squads" that each find the best target for them
|
|
||||||
if(total < minSquadSize) return null;
|
|
||||||
|
|
||||||
targets.clear();
|
targets.clear();
|
||||||
for(var flag : flags){
|
for(var flag : flags){
|
||||||
targets.addAll(Vars.indexer.getEnemy(data.team, flag));
|
targets.addAll(Vars.indexer.getEnemy(data.team, flag));
|
||||||
}
|
}
|
||||||
|
targets.removeAll(b -> assignedTargets.contains(b.id));
|
||||||
|
|
||||||
if(targets.size == 0) return null;
|
if(targets.size == 0) return null;
|
||||||
|
|
||||||
@@ -100,18 +215,21 @@ public class RtsAI{
|
|||||||
weights.put(target, estimateStats(target.x, target.y, dps, health));
|
weights.put(target, estimateStats(target.x, target.y, dps, health));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = targets.min(b -> {
|
var result = targets.min(
|
||||||
float w = 1f - weights.get(b, 0f);
|
Structs.comps(
|
||||||
|
//weight is most important?
|
||||||
//TODO more weighting, e.g. distance
|
Structs.comparingFloat(b -> (1f - weights.get(b, 0f)) + b.dst(x, y)/10000f),
|
||||||
return w;
|
//then distance TODO why weight above
|
||||||
});
|
Structs.comparingFloat(b -> b.dst2(x, y))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
float weight = weights.get(result, 0f);
|
float weight = weights.get(result, 0f);
|
||||||
if(weight < minWeight && total < Units.getCap(data.team)){
|
if(weight < minWeight && total < Units.getCap(data.team)){
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assignedTargets.add(result.id);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,7 @@ public class CommandAI extends AIController{
|
|||||||
targetPos.set(attackTarget);
|
targetPos.set(attackTarget);
|
||||||
|
|
||||||
if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.pathType() != Pathfinder.costLegs){
|
if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.pathType() != Pathfinder.costLegs){
|
||||||
Tile best = null;
|
Tile best = build.findClosestEdge(unit, Tile::solid);
|
||||||
float mindst = 0f;
|
|
||||||
for(var point : Edges.getEdges(build.block.size)){
|
|
||||||
Tile tile = Vars.world.tile(build.tile.x + point.x, build.tile.y + point.y);
|
|
||||||
if(tile != null && !tile.solid() && (best == null || unit.dst2(tile) < mindst)){
|
|
||||||
best = tile;
|
|
||||||
mindst = unit.dst2(tile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(best != null){
|
if(best != null){
|
||||||
targetPos.set(best);
|
targetPos.set(best);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,11 +415,11 @@ public class Damage{
|
|||||||
|
|
||||||
/** Damages all entities and blocks in a radius that are enemies of the team. */
|
/** Damages all entities and blocks in a radius that are enemies of the team. */
|
||||||
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground){
|
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground){
|
||||||
damage(team, x, y, radius, damage, complete, air, ground, false);
|
damage(team, x, y, radius, damage, complete, air, ground, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Damages all entities and blocks in a radius that are enemies of the team. */
|
/** Damages all entities and blocks in a radius that are enemies of the team. */
|
||||||
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground, boolean scaled){
|
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground, boolean scaled, Bullet source){
|
||||||
Cons<Unit> cons = entity -> {
|
Cons<Unit> cons = entity -> {
|
||||||
if(entity.team == team || !entity.within(x, y, radius + (scaled ? entity.hitSize / 2f : 0f)) || (entity.isFlying() && !air) || (entity.isGrounded() && !ground)){
|
if(entity.team == team || !entity.within(x, y, radius + (scaled ? entity.hitSize / 2f : 0f)) || (entity.isFlying() && !air) || (entity.isGrounded() && !ground)){
|
||||||
return;
|
return;
|
||||||
@@ -445,7 +445,7 @@ public class Damage{
|
|||||||
|
|
||||||
if(ground){
|
if(ground){
|
||||||
if(!complete){
|
if(!complete){
|
||||||
tileDamage(team, World.toTile(x), World.toTile(y), radius / tilesize, damage);
|
tileDamage(team, World.toTile(x), World.toTile(y), radius / tilesize, damage, source);
|
||||||
}else{
|
}else{
|
||||||
completeDamage(team, x, y, radius, damage);
|
completeDamage(team, x, y, radius, damage);
|
||||||
}
|
}
|
||||||
@@ -453,6 +453,10 @@ public class Damage{
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void tileDamage(Team team, int x, int y, float baseRadius, float damage){
|
public static void tileDamage(Team team, int x, int y, float baseRadius, float damage){
|
||||||
|
tileDamage(team, x, y, baseRadius, damage, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void tileDamage(Team team, int x, int y, float baseRadius, float damage, @Nullable Bullet source){
|
||||||
|
|
||||||
Core.app.post(() -> {
|
Core.app.post(() -> {
|
||||||
|
|
||||||
@@ -519,7 +523,11 @@ public class Damage{
|
|||||||
int cx = Point2.x(e.key), cy = Point2.y(e.key);
|
int cx = Point2.x(e.key), cy = Point2.y(e.key);
|
||||||
var build = world.build(cx, cy);
|
var build = world.build(cx, cy);
|
||||||
if(build != null){
|
if(build != null){
|
||||||
build.damage(team, e.value);
|
if(source != null){
|
||||||
|
build.damage(source, team, e.value);
|
||||||
|
}else{
|
||||||
|
build.damage(team, e.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ public class BulletType extends Content implements Cloneable{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(splashDamageRadius > 0 && !b.absorbed){
|
if(splashDamageRadius > 0 && !b.absorbed){
|
||||||
Damage.damage(b.team, x, y, splashDamageRadius, splashDamage * b.damageMultiplier(), false, collidesAir, collidesGround, scaledSplashDamage);
|
Damage.damage(b.team, x, y, splashDamageRadius, splashDamage * b.damageMultiplier(), false, collidesAir, collidesGround, scaledSplashDamage, b);
|
||||||
|
|
||||||
if(status != StatusEffects.none){
|
if(status != StatusEffects.none){
|
||||||
Damage.status(b.team, x, y, splashDamageRadius, status, statusDuration, collidesAir, collidesGround);
|
Damage.status(b.team, x, y, splashDamageRadius, status, statusDuration, collidesAir, collidesGround);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ abstract class BuildingComp implements Posc, Teamc, Healthc, Buildingc, Timerc,
|
|||||||
static final ObjectSet<Building> tmpTiles = new ObjectSet<>();
|
static final ObjectSet<Building> tmpTiles = new ObjectSet<>();
|
||||||
static final Seq<Building> tempBuilds = new Seq<>();
|
static final Seq<Building> tempBuilds = new Seq<>();
|
||||||
static final BuildTeamChangeEvent teamChangeEvent = new BuildTeamChangeEvent();
|
static final BuildTeamChangeEvent teamChangeEvent = new BuildTeamChangeEvent();
|
||||||
|
static final BuildDamageEvent bulletDamageEvent = new BuildDamageEvent();
|
||||||
static int sleepingEntities = 0;
|
static int sleepingEntities = 0;
|
||||||
|
|
||||||
@Import float x, y, health, maxHealth;
|
@Import float x, y, health, maxHealth;
|
||||||
@@ -262,6 +263,19 @@ abstract class BuildingComp implements Posc, Teamc, Healthc, Buildingc, Timerc,
|
|||||||
data.blocks.addFirst(new BlockPlan(tile.x, tile.y, (short)rotation, block.id, overrideConfig == null ? config() : overrideConfig));
|
data.blocks.addFirst(new BlockPlan(tile.x, tile.y, (short)rotation, block.id, overrideConfig == null ? config() : overrideConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @Nullable Tile findClosestEdge(Position to, Boolf<Tile> solid){
|
||||||
|
Tile best = null;
|
||||||
|
float mindst = 0f;
|
||||||
|
for(var point : Edges.getEdges(block.size)){
|
||||||
|
Tile other = Vars.world.tile(tile.x + point.x, tile.y + point.y);
|
||||||
|
if(other != null && !solid.get(other) && (best == null || to.dst2(other) < mindst)){
|
||||||
|
best = other;
|
||||||
|
mindst = other.dst2(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** Configure with the current, local player. */
|
/** Configure with the current, local player. */
|
||||||
public void configure(Object value){
|
public void configure(Object value){
|
||||||
//save last used config
|
//save last used config
|
||||||
@@ -1498,6 +1512,7 @@ abstract class BuildingComp implements Posc, Teamc, Healthc, Buildingc, Timerc,
|
|||||||
* @return whether the bullet should be removed. */
|
* @return whether the bullet should be removed. */
|
||||||
public boolean collision(Bullet other){
|
public boolean collision(Bullet other){
|
||||||
damage(other.team, other.damage() * other.type().buildingDamageMultiplier);
|
damage(other.team, other.damage() * other.type().buildingDamageMultiplier);
|
||||||
|
Events.fire(bulletDamageEvent.set(self(), other));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1507,6 +1522,12 @@ abstract class BuildingComp implements Posc, Teamc, Healthc, Buildingc, Timerc,
|
|||||||
damage(damage);
|
damage(damage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles splash damage with a bullet source. */
|
||||||
|
public void damage(Bullet bullet, Team source, float damage){
|
||||||
|
damage(source, damage);
|
||||||
|
Events.fire(bulletDamageEvent.set(self(), bullet));
|
||||||
|
}
|
||||||
|
|
||||||
/** Changes this building's team in a safe manner. */
|
/** Changes this building's team in a safe manner. */
|
||||||
public void changeTeam(Team next){
|
public void changeTeam(Team next){
|
||||||
Team last = this.team;
|
Team last = this.team;
|
||||||
|
|||||||
@@ -249,6 +249,21 @@ public class EventType{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a bullet damages a building. May not be called for all damage events!
|
||||||
|
* This event is re-used! Never do anything to re-raise this event in the listener.
|
||||||
|
* */
|
||||||
|
public static class BuildDamageEvent{
|
||||||
|
public Building build;
|
||||||
|
public Bullet source;
|
||||||
|
|
||||||
|
public BuildDamageEvent set(Building build, Bullet source){
|
||||||
|
this.build = build;
|
||||||
|
this.source = source;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called *before* a tile has changed.
|
* Called *before* a tile has changed.
|
||||||
* WARNING! This event is special: its instance is reused! Do not cache or use with a timer.
|
* WARNING! This event is special: its instance is reused! Do not cache or use with a timer.
|
||||||
|
|||||||
@@ -237,8 +237,7 @@ public class Rules{
|
|||||||
|
|
||||||
public TeamRule get(Team team){
|
public TeamRule get(Team team){
|
||||||
TeamRule out = values[team.id];
|
TeamRule out = values[team.id];
|
||||||
if(out == null) values[team.id] = (out = new TeamRule());
|
return out == null ? (values[team.id] = new TeamRule()) : out;
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
Reference in New Issue
Block a user