Content patcher progress
This commit is contained in:
@@ -3,9 +3,11 @@ package mindustry.ctype;
|
|||||||
import arc.files.*;
|
import arc.files.*;
|
||||||
import arc.util.*;
|
import arc.util.*;
|
||||||
import mindustry.*;
|
import mindustry.*;
|
||||||
|
import mindustry.mod.*;
|
||||||
import mindustry.mod.Mods.*;
|
import mindustry.mod.Mods.*;
|
||||||
|
|
||||||
/** Base class for a content type that is loaded in {@link mindustry.core.ContentLoader}. */
|
/** Base class for a content type that is loaded in {@link mindustry.core.ContentLoader}. */
|
||||||
|
@NoPatch
|
||||||
public abstract class Content implements Comparable<Content>{
|
public abstract class Content implements Comparable<Content>{
|
||||||
public short id;
|
public short id;
|
||||||
/** Info on which mod this content was loaded from. */
|
/** Info on which mod this content was loaded from. */
|
||||||
@@ -28,6 +30,9 @@ public abstract class Content implements Comparable<Content>{
|
|||||||
/** Called after init(). */
|
/** Called after init(). */
|
||||||
public void postInit(){}
|
public void postInit(){}
|
||||||
|
|
||||||
|
/** Called after being patched. */
|
||||||
|
public void afterPatch(){}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after all content is created, only on non-headless versions.
|
* Called after all content is created, only on non-headless versions.
|
||||||
* Use for loading regions or other image data.
|
* Use for loading regions or other image data.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package mindustry.ctype;
|
package mindustry.ctype;
|
||||||
|
|
||||||
import mindustry.*;
|
import mindustry.*;
|
||||||
|
import mindustry.mod.*;
|
||||||
|
|
||||||
|
@NoPatch
|
||||||
public abstract class MappableContent extends Content{
|
public abstract class MappableContent extends Content{
|
||||||
public final String name;
|
public final String name;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import mindustry.content.TechTree.*;
|
|||||||
import mindustry.game.EventType.*;
|
import mindustry.game.EventType.*;
|
||||||
import mindustry.graphics.*;
|
import mindustry.graphics.*;
|
||||||
import mindustry.graphics.MultiPacker.*;
|
import mindustry.graphics.MultiPacker.*;
|
||||||
|
import mindustry.mod.*;
|
||||||
import mindustry.type.*;
|
import mindustry.type.*;
|
||||||
import mindustry.ui.*;
|
import mindustry.ui.*;
|
||||||
import mindustry.world.meta.*;
|
import mindustry.world.meta.*;
|
||||||
@@ -64,6 +65,7 @@ public abstract class UnlockableContent extends MappableContent{
|
|||||||
/** Tech nodes for all trees that this content is part of. */
|
/** Tech nodes for all trees that this content is part of. */
|
||||||
public Seq<TechNode> techNodes = new Seq<>();
|
public Seq<TechNode> techNodes = new Seq<>();
|
||||||
/** Unlock state. Loaded from settings. Do not modify outside the constructor. */
|
/** Unlock state. Loaded from settings. Do not modify outside the constructor. */
|
||||||
|
@NoPatch
|
||||||
protected boolean unlocked;
|
protected boolean unlocked;
|
||||||
|
|
||||||
public UnlockableContent(String name){
|
public UnlockableContent(String name){
|
||||||
|
|||||||
@@ -1105,7 +1105,7 @@ public class ContentParser{
|
|||||||
FieldMetadata metadata = fields.get(child.name().replace(" ", "_"));
|
FieldMetadata metadata = fields.get(child.name().replace(" ", "_"));
|
||||||
if(metadata == null){
|
if(metadata == null){
|
||||||
if(ignoreUnknownFields){
|
if(ignoreUnknownFields){
|
||||||
Log.warn("[@]: Ignoring unknown field: @ (@)", currentContent.minfo.sourceFile.name(), child.name, type.getSimpleName());
|
Log.warn("[@]: Ignoring unknown field: @ (@)", currentContent == null ? null : currentContent.minfo.sourceFile.name(), child.name, type.getSimpleName());
|
||||||
continue;
|
continue;
|
||||||
}else{
|
}else{
|
||||||
SerializationException ex = new SerializationException("Field not found: " + child.name + " (" + type.getName() + ")");
|
SerializationException ex = new SerializationException("Field not found: " + child.name + " (" + type.getName() + ")");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import mindustry.ctype.*;
|
|||||||
import java.lang.reflect.*;
|
import java.lang.reflect.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
/** The current implementation is awful. Consider it a proof of concept. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public class ContentPatcher{
|
public class ContentPatcher{
|
||||||
private static final Object root = new Object();
|
private static final Object root = new Object();
|
||||||
@@ -22,8 +23,8 @@ public class ContentPatcher{
|
|||||||
private boolean applied;
|
private boolean applied;
|
||||||
private ContentLoader contentLoader;
|
private ContentLoader contentLoader;
|
||||||
private ObjectSet<PatchRecord> usedpatches = new ObjectSet<>();
|
private ObjectSet<PatchRecord> usedpatches = new ObjectSet<>();
|
||||||
private Seq<PatchRecord> patches = new Seq<>();
|
|
||||||
private Seq<Runnable> resetters = new Seq<>();
|
private Seq<Runnable> resetters = new Seq<>();
|
||||||
|
private Seq<Runnable> afterCallbacks = new Seq<>();
|
||||||
|
|
||||||
static{
|
static{
|
||||||
for(var type : ContentType.all){
|
for(var type : ContentType.all){
|
||||||
@@ -41,6 +42,8 @@ public class ContentPatcher{
|
|||||||
for(var child : value){
|
for(var child : value){
|
||||||
assign(root, child.name, child, null, null, null);
|
assign(root, child.name, child, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterCallbacks.each(Runnable::run);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void unapply() throws Exception{
|
public void unapply() throws Exception{
|
||||||
@@ -49,21 +52,26 @@ public class ContentPatcher{
|
|||||||
Vars.content = contentLoader;
|
Vars.content = contentLoader;
|
||||||
applied = false;
|
applied = false;
|
||||||
|
|
||||||
for(var record : patches){
|
resetters.reverse();
|
||||||
assign(record.target, record.field, record.value, record.data, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetters.each(Runnable::run);
|
resetters.each(Runnable::run);
|
||||||
resetters.clear();
|
resetters.clear();
|
||||||
|
|
||||||
|
afterCallbacks.each(Runnable::run);
|
||||||
|
afterCallbacks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void assign(Object object, String field, Object value, @Nullable FieldMetadata metadata, @Nullable Object parentObject, @Nullable String parentField) throws Exception{
|
void assign(Object object, String field, Object value, @Nullable FieldData metadata, @Nullable Object parentObject, @Nullable String parentField) throws Exception{
|
||||||
if(field == null || field.isEmpty()) return;
|
if(field == null || field.isEmpty()) return;
|
||||||
|
|
||||||
|
char prefix = 0;
|
||||||
|
|
||||||
//fetch modifier (+ or -) and concat it to the end, turning `+array` into `array.+`
|
//fetch modifier (+ or -) and concat it to the end, turning `+array` into `array.+`
|
||||||
if(field.charAt(0) == '-' || field.charAt(0) == '+'){
|
if(field.charAt(0) == '+'){
|
||||||
char prefix = field.charAt(0);
|
prefix = field.charAt(0);
|
||||||
field = field.substring(1) + "." + prefix;
|
field = field.substring(1);
|
||||||
|
}else if(field.endsWith(".+")){
|
||||||
|
prefix = field.charAt(field.length() - 1);
|
||||||
|
field = field.substring(0, field.length() - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
//field.field2.field3 nested syntax
|
//field.field2.field3 nested syntax
|
||||||
@@ -71,29 +79,44 @@ public class ContentPatcher{
|
|||||||
//resolve the field chain until the final field is reached
|
//resolve the field chain until the final field is reached
|
||||||
String[] path = field.split("\\.");
|
String[] path = field.split("\\.");
|
||||||
for(int i = 0; i < path.length - 1; i++){
|
for(int i = 0; i < path.length - 1; i++){
|
||||||
Object[] result = resolve(object, path[i], null, null);
|
Object[] result = resolve(object, path[i], metadata);
|
||||||
if(result == null){
|
if(result == null){
|
||||||
//TODO report error
|
//TODO report error
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
object = result[0];
|
object = result[0];
|
||||||
metadata = (FieldMetadata)result[1];
|
metadata = (FieldData)result[1];
|
||||||
}
|
}
|
||||||
field = path[path.length - 1];
|
field = path[path.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(object instanceof Content c){
|
||||||
|
after(c::afterPatch);
|
||||||
|
}
|
||||||
|
|
||||||
if(object == root){
|
if(object == root){
|
||||||
warn("Content cannot be assigned.");
|
if(value instanceof JsonValue jval && jval.isObject()){
|
||||||
|
for(var child : jval){
|
||||||
|
Object[] otherResolve = resolve(object, jval.name, null);
|
||||||
|
if(otherResolve != null && otherResolve[0] instanceof ObjectMap map && map.containsKey(child.name)){
|
||||||
|
assign(otherResolve[0], child.name, child, (FieldData)otherResolve[1], object, field);
|
||||||
|
}else{
|
||||||
|
Log.warn("Content not found: @.@", field, child.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
warn("Content '@' cannot be assigned.", field);
|
||||||
|
}
|
||||||
}else if(object instanceof Seq<?> || object.getClass().isArray()){ //TODO
|
}else if(object instanceof Seq<?> || object.getClass().isArray()){ //TODO
|
||||||
|
|
||||||
if(field.length() == 1 && (field.charAt(0) == '+')){
|
if(prefix == '+'){
|
||||||
//handle array addition syntax
|
//handle array addition syntax
|
||||||
if(object instanceof Seq s){
|
if(object instanceof Seq s){
|
||||||
modified(parentObject, parentField, s.copy(), null);
|
modifiedField(parentObject, parentField, s.copy());
|
||||||
|
|
||||||
assignValue(object, field, metadata, () -> null, val -> s.add(val), value, false);
|
assignValue(object, field, metadata, () -> null, val -> s.add(val), value, false);
|
||||||
}else{
|
}else{
|
||||||
modified(parentObject, parentField, copyArray(object), null);
|
modifiedField(parentObject, parentField, copyArray(object));
|
||||||
|
|
||||||
var fobj = object;
|
var fobj = object;
|
||||||
assignValue(parentObject, parentField, metadata, () -> null, val -> {
|
assignValue(parentObject, parentField, metadata, () -> null, val -> {
|
||||||
@@ -123,28 +146,33 @@ public class ContentPatcher{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(object instanceof Seq s){
|
if(object instanceof Seq s){
|
||||||
modified(parentObject, parentField, s.copy(), null);
|
var copy = s.copy();
|
||||||
|
reset(() -> s.set(copy));
|
||||||
|
|
||||||
assignValue(object, field, metadata, () -> s.get(i), val -> s.set(i, val), value, false);
|
assignValue(object, field, metadata, () -> s.get(i), val -> s.set(i, val), value, false);
|
||||||
}else{
|
}else{
|
||||||
modified(parentObject, parentField, copyArray(object), null);
|
modifiedField(parentObject, parentField, copyArray(object));
|
||||||
|
|
||||||
var fobj = object;
|
var fobj = object;
|
||||||
assignValue(object, field, metadata, () -> Array.get(fobj, i), val -> Array.set(fobj, i, val), value, false);
|
assignValue(object, field, metadata, () -> Array.get(fobj, i), val -> Array.set(fobj, i, val), value, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}else if(object instanceof ObjectMap map){ //TODO
|
}else if(object instanceof ObjectMap map){
|
||||||
if(metadata == null){
|
if(metadata == null){
|
||||||
warn("ObjectMap cannot be parsed without metadata.");
|
warn("ObjectMap cannot be parsed without metadata: @.@", parentObject, parentField);
|
||||||
return;
|
throw new RuntimeException();
|
||||||
|
//return;
|
||||||
}
|
}
|
||||||
Object key = convertKeyType(field, metadata.keyType);
|
Object key = convertKeyType(field, metadata.keyType);
|
||||||
if(key == null){
|
if(key == null){
|
||||||
warn("Null key: '@'", field);
|
warn("Null key: '@'", field);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modified(parentObject, parentField, map.copy(), metadata);
|
|
||||||
assignValue(object, field, metadata, () -> map.get(key), val -> map.put(key, val), value, false);
|
var copy = map.copy();
|
||||||
|
reset(() -> map.set(copy));
|
||||||
|
|
||||||
|
assignValue(object, field, new FieldData(metadata.elementType, null, null), () -> map.get(key), val -> map.put(key, val), value, false);
|
||||||
}else{
|
}else{
|
||||||
Class<?> actualType = object.getClass();
|
Class<?> actualType = object.getClass();
|
||||||
if(actualType.isAnonymousClass()) actualType = actualType.getSuperclass();
|
if(actualType.isAnonymousClass()) actualType = actualType.getSuperclass();
|
||||||
@@ -155,32 +183,36 @@ public class ContentPatcher{
|
|||||||
if(checkField(fdata.field)) return;
|
if(checkField(fdata.field)) return;
|
||||||
|
|
||||||
var fobj = object;
|
var fobj = object;
|
||||||
assignValue(object, field, metadata, () -> Reflect.get(fobj, fdata.field), fv -> Reflect.set(fobj, fdata.field, fv), value, true);
|
assignValue(object, field, new FieldData(fdata), () -> Reflect.get(fobj, fdata.field), fv -> Reflect.set(fobj, fdata.field, fv), value, true);
|
||||||
}else{
|
}else{
|
||||||
warn("Unknown field: '@' for '@'", field, actualType.getName());
|
warn("Unknown field: '@' for '@'", field, actualType.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void assignValue(Object object, String field, FieldMetadata metadata, Prov getter, Cons setter, Object value, boolean modify) throws Exception{
|
void assignValue(Object object, String field, FieldData metadata, Prov getter, Cons setter, Object value, boolean modify) throws Exception{
|
||||||
Object prevValue = getter.get();
|
Object prevValue = getter.get();
|
||||||
|
|
||||||
if(value instanceof JsonValue jsv){ //setting values from object
|
if(value instanceof JsonValue jsv){ //setting values from object
|
||||||
if(prevValue == null){
|
if(prevValue == null || !jsv.isObject() || jsv.has("type")){
|
||||||
if(modify) modified(object, field, null, metadata);
|
if(modify) modifiedField(object, field, getter.get());
|
||||||
setter.get(json.readValue(metadata.field.getType(), metadata.elementType, jsv));
|
try{
|
||||||
|
setter.get(json.readValue(metadata.type, metadata.elementType, jsv));
|
||||||
|
}catch(Throwable e){
|
||||||
|
warn("Failed to read value @.@ = @: @ (type = @ elementType = @)", object, field, value, e.getMessage(), metadata.type, metadata.elementType);
|
||||||
|
}
|
||||||
}else{
|
}else{
|
||||||
//assign each field manually
|
//assign each field manually
|
||||||
var childFields = json.getFields(prevValue.getClass().isAnonymousClass() ? prevValue.getClass().getSuperclass() : prevValue.getClass());
|
var childFields = json.getFields(prevValue.getClass().isAnonymousClass() ? prevValue.getClass().getSuperclass() : prevValue.getClass());
|
||||||
for(var child : jsv){
|
for(var child : jsv){
|
||||||
if(child.name != null){
|
if(child.name != null){
|
||||||
assign(prevValue, child.name, child, childFields.get(child.name), object, field);
|
assign(prevValue, child.name, child, !childFields.containsKey(child.name) ? null : new FieldData(childFields.get(child.name)), object, field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}else{
|
}else{
|
||||||
//direct value is set
|
//direct value is set
|
||||||
if(modify) modified(object, field, prevValue, metadata);
|
if(modify) modifiedField(object, field, prevValue);
|
||||||
|
|
||||||
setter.get(value);
|
setter.get(value);
|
||||||
}
|
}
|
||||||
@@ -190,7 +222,7 @@ public class ContentPatcher{
|
|||||||
* 0: the object
|
* 0: the object
|
||||||
* 1: the field metadata for the object to use with deserializing collection types
|
* 1: the field metadata for the object to use with deserializing collection types
|
||||||
* */
|
* */
|
||||||
Object[] resolve(Object object, String field, Object value, @Nullable FieldMetadata metadata) throws Exception{
|
Object[] resolve(Object object, String field, @Nullable FieldData metadata) throws Exception{
|
||||||
if(object == null) return null;
|
if(object == null) return null;
|
||||||
|
|
||||||
if(object == root){
|
if(object == root){
|
||||||
@@ -199,7 +231,7 @@ public class ContentPatcher{
|
|||||||
warn("Invalid content type: " + field);
|
warn("Invalid content type: " + field);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new Object[]{Vars.content.getNamesBy(ctype), new FieldMetadata(null, MappableContent.class, String.class)};
|
return new Object[]{Vars.content.getNamesBy(ctype), new FieldData(ObjectMap.class, MappableContent.class, String.class)};
|
||||||
}else if(object instanceof Seq<?> || object.getClass().isArray()){
|
}else if(object instanceof Seq<?> || object.getClass().isArray()){
|
||||||
int i = Strings.parseInt(field);
|
int i = Strings.parseInt(field);
|
||||||
int length = object instanceof Seq s ? s.size : Array.getLength(object);
|
int length = object instanceof Seq s ? s.size : Array.getLength(object);
|
||||||
@@ -234,7 +266,7 @@ public class ContentPatcher{
|
|||||||
if(fdata != null){
|
if(fdata != null){
|
||||||
if(checkField(fdata.field)) return null;
|
if(checkField(fdata.field)) return null;
|
||||||
|
|
||||||
return new Object[]{fdata.field.get(object), fdata};
|
return new Object[]{fdata.field.get(object), new FieldData(fdata)};
|
||||||
}else{
|
}else{
|
||||||
warn("Unknown field: '@' for '@'", field, actualType.getName());
|
warn("Unknown field: '@' for '@'", field, actualType.getName());
|
||||||
return null;
|
return null;
|
||||||
@@ -250,14 +282,30 @@ public class ContentPatcher{
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void modified(Object target, String field, Object value, FieldMetadata data){
|
void modifiedField(Object target, String field, Object value){
|
||||||
if(!applied) return;
|
if(!applied || target == null) return;
|
||||||
|
|
||||||
//TODO
|
var fields = json.getFields(target.getClass());
|
||||||
var record = new PatchRecord(target, field, value, data);
|
var meta = fields.get(field);
|
||||||
|
if(meta != null){
|
||||||
|
|
||||||
|
var record = new PatchRecord(target, meta.field, value);
|
||||||
if(usedpatches.add(record)){
|
if(usedpatches.add(record)){
|
||||||
patches.add(record);
|
resetters.add(() -> {
|
||||||
|
try{
|
||||||
|
record.field.set(record.target, record.value);
|
||||||
|
}catch(Exception e){
|
||||||
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
warn("Missing field " + field + " for object " + target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset(Runnable run){
|
||||||
|
resetters.add(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object convertKeyType(String string, Class<?> type){
|
Object convertKeyType(String string, Class<?> type){
|
||||||
@@ -269,8 +317,8 @@ public class ContentPatcher{
|
|||||||
Log.warn(error, fmt);
|
Log.warn(error, fmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset(Runnable run){
|
void after(Runnable run){
|
||||||
resetters.add(run);
|
afterCallbacks.add(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Object copyArray(Object object){
|
static Object copyArray(Object object){
|
||||||
@@ -285,17 +333,29 @@ public class ContentPatcher{
|
|||||||
return ((Object[])object).clone();
|
return ((Object[])object).clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class FieldData{
|
||||||
|
Class type, elementType, keyType;
|
||||||
|
|
||||||
|
public FieldData(Class type, Class elementType, Class keyType){
|
||||||
|
this.type = type;
|
||||||
|
this.elementType = elementType;
|
||||||
|
this.keyType = keyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FieldData(FieldMetadata data){
|
||||||
|
this(data.field.getType(), data.elementType, data.keyType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static class PatchRecord{
|
private static class PatchRecord{
|
||||||
Object target;
|
Object target;
|
||||||
String field;
|
Field field;
|
||||||
Object value;
|
Object value;
|
||||||
FieldMetadata data;
|
|
||||||
|
|
||||||
PatchRecord(Object target, String field, Object value, FieldMetadata data){
|
PatchRecord(Object target, Field field, Object value){
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.field = field;
|
this.field = field;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.data = data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mindustry.mod;
|
|||||||
|
|
||||||
import java.lang.annotation.*;
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/** Indicates that a field cannot be edited by the content patcher. */
|
||||||
@Target({ElementType.FIELD, ElementType.TYPE})
|
@Target({ElementType.FIELD, ElementType.TYPE})
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
public @interface NoPatch{
|
public @interface NoPatch{
|
||||||
|
|||||||
@@ -700,6 +700,12 @@ public class Block extends UnlockableContent implements Senseable{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterPatch(){
|
||||||
|
barMap.clear();
|
||||||
|
setBars();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean consumesItem(Item item){
|
public boolean consumesItem(Item item){
|
||||||
return itemFilter[item.id];
|
return itemFilter[item.id];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ org.gradle.caching=true
|
|||||||
org.gradle.internal.http.socketTimeout=100000
|
org.gradle.internal.http.socketTimeout=100000
|
||||||
org.gradle.internal.http.connectionTimeout=100000
|
org.gradle.internal.http.connectionTimeout=100000
|
||||||
android.enableR8.fullMode=false
|
android.enableR8.fullMode=false
|
||||||
archash=e07415cdae
|
archash=7a3d906e1b
|
||||||
|
|||||||
Reference in New Issue
Block a user