Public Access
This commit is contained in:
1 parent
8cce41fd71
commit
3c900634c9
15 files changed
+354
-46
No files matched your search
@@ -42,6 +42,11 @@ public final class CoreGameTests {
|
||||
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void semanticSymbolSchema(GameTestHelper helper) {
|
||||
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void notebookPersistence(GameTestHelper helper) {
|
||||
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.util.List;
|
||||
@@ -8,6 +9,8 @@ import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
@@ -100,6 +103,62 @@ public final class WritingDataGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void semanticSchemaIsServerOnlyBoundedAndV1Compatible(GameTestHelper helper) {
|
||||
var builtIn = SymbolCatalog.find(reference("rivencraft:terrain/standard")).orElseThrow();
|
||||
helper.assertValueEqual(builtIn.schemaVersion(), 2, "Built-in symbol must use semantic schema v2");
|
||||
helper.assertValueEqual(builtIn.semantics().orElseThrow().role(), SymbolSemantics.Role.TERRAIN,
|
||||
"Built-in terrain symbol must declare its grammar role");
|
||||
|
||||
String display = "\"name\":\"symbol.example.test\","
|
||||
+ "\"description\":\"symbol.example.test.description\","
|
||||
+ "\"category\":\"example:terrain\"";
|
||||
SymbolDefinition legacy = SymbolDefinition.parse(JsonParser.parseString(
|
||||
"{\"schema_version\":1," + display + "}"));
|
||||
helper.assertTrue(legacy.semantics().isEmpty(), "Existing v1 definitions must remain display-only");
|
||||
SymbolDefinition oldCategory = SymbolDefinition.parse(JsonParser.parseString(
|
||||
"{\"schema_version\":1,\"name\":\"symbol.example.test\","
|
||||
+ "\"description\":\"symbol.example.test.description\","
|
||||
+ "\"category\":\"terrain\"}"));
|
||||
helper.assertValueEqual(oldCategory.category().toString(), "minecraft:terrain",
|
||||
"A v1 category without a namespace must retain the old ResourceLocation behavior");
|
||||
helper.assertValueEqual(legacy.display(reference("example:test").id()).id(),
|
||||
reference("example:test").id(), "Display metadata must preserve the symbol ID");
|
||||
|
||||
SymbolDefinition period = SymbolDefinition.parse(JsonParser.parseString(
|
||||
"{\"schema_version\":2," + display
|
||||
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":0}}"));
|
||||
helper.assertValueEqual(period.semantics().orElseThrow().role(), SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||
"Zero-length modifier must be accepted");
|
||||
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||
+ ",\"semantics\":{\"role\":\"unknown\",\"value\":\"normal\"}}", "role");
|
||||
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"blizzard\"}}", "value");
|
||||
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":2.01}}", "value");
|
||||
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||
+ ",\"semantics\":{\"role\":\"terrain\",\"value\":\"minecraft:overworld\",\"extra\":1}}", "extra");
|
||||
rejectDefinition(helper, "{\"schema_version\":1," + display
|
||||
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"}}", "v1");
|
||||
|
||||
var clientJson = builtIn.display(reference("rivencraft:terrain/standard").id());
|
||||
var encoded = net.dnorberg.rivencraft.writing.SymbolDisplayMetadata.CODEC
|
||||
.encodeStart(JsonOps.INSTANCE, clientJson).result().orElseThrow();
|
||||
helper.assertFalse(encoded.getAsJsonObject().has("semantics"),
|
||||
"Client display payload must never contain generation semantics");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static void rejectDefinition(GameTestHelper helper, String json, String diagnostic) {
|
||||
boolean rejected = false;
|
||||
try {
|
||||
SymbolDefinition.parse(JsonParser.parseString(json));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
rejected = exception.getMessage().contains(diagnostic);
|
||||
}
|
||||
helper.assertTrue(rejected, "Invalid symbol must be rejected with diagnostic containing '" + diagnostic + "'");
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
|
||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||
|
||||
@@ -3,7 +3,6 @@ package net.dnorberg.rivencraft.writing;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -56,10 +55,13 @@ public final class SymbolCatalog {
|
||||
ResourceManager resourceManager,
|
||||
ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
|
||||
resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json)
|
||||
.resultOrPartial(error -> Rivencraft.LOGGER.error(
|
||||
"Ignoring invalid writing symbol {}: {}", id, error))
|
||||
.ifPresent(definition -> loaded.put(id, definition)));
|
||||
resources.forEach((id, json) -> {
|
||||
try {
|
||||
loaded.put(id, SymbolDefinition.parse(json));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage());
|
||||
}
|
||||
});
|
||||
symbols = Map.copyOf(loaded);
|
||||
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
||||
}
|
||||
|
||||
@@ -1,47 +1,120 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Server-loaded, non-executable display metadata for a writing symbol. */
|
||||
/** Server-only symbol definition. Only display metadata crosses to clients. */
|
||||
public record SymbolDefinition(
|
||||
int schemaVersion,
|
||||
String nameTranslationKey,
|
||||
String descriptionTranslationKey,
|
||||
ResourceLocation category
|
||||
ResourceLocation category,
|
||||
Optional<SymbolSemantics> semantics
|
||||
) {
|
||||
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
public static final int CURRENT_SCHEMA_VERSION = 2;
|
||||
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
|
||||
private static final Codec<String> TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap(
|
||||
value -> TRANSLATION_KEY.matcher(value).matches()
|
||||
? DataResult.success(value)
|
||||
: DataResult.error(() -> "Invalid translation key: " + value),
|
||||
value -> value
|
||||
);
|
||||
public static final Codec<SymbolDefinition> CODEC = RecordCodecBuilder.<SymbolDefinition>create(instance -> instance.group(
|
||||
Codec.INT.fieldOf("schema_version").forGetter(SymbolDefinition::schemaVersion),
|
||||
TRANSLATION_KEY_CODEC.fieldOf("name").forGetter(SymbolDefinition::nameTranslationKey),
|
||||
TRANSLATION_KEY_CODEC.fieldOf("description").forGetter(SymbolDefinition::descriptionTranslationKey),
|
||||
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDefinition::category)
|
||||
).apply(instance, SymbolDefinition::new)).flatXmap(SymbolDefinition::validate, SymbolDefinition::validate);
|
||||
private static final Set<String> V2_FIELDS = Set.of(
|
||||
"schema_version", "name", "description", "category", "semantics");
|
||||
|
||||
public SymbolDefinition {
|
||||
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
|
||||
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
|
||||
Objects.requireNonNull(category, "category");
|
||||
semantics = Objects.requireNonNull(semantics, "semantics");
|
||||
if (schemaVersion != 1 && schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||
throw new IllegalArgumentException("Unsupported schema_version " + schemaVersion);
|
||||
}
|
||||
if (schemaVersion == 1 && semantics.isPresent()) {
|
||||
throw new IllegalArgumentException("Schema v1 symbols cannot have semantics");
|
||||
}
|
||||
if (schemaVersion == CURRENT_SCHEMA_VERSION && semantics.isEmpty()) {
|
||||
throw new IllegalArgumentException("Schema v2 symbols require semantics");
|
||||
}
|
||||
validateTranslationKey(nameTranslationKey, "name");
|
||||
validateTranslationKey(descriptionTranslationKey, "description");
|
||||
}
|
||||
|
||||
/** v1 remains display-only; v2 has an exact field set and typed semantics. */
|
||||
public static SymbolDefinition parse(JsonElement json) {
|
||||
if (json == null || !json.isJsonObject()) {
|
||||
throw new IllegalArgumentException("Symbol definition must be a JSON object");
|
||||
}
|
||||
JsonObject object = json.getAsJsonObject();
|
||||
int version = requiredInteger(object, "schema_version");
|
||||
if (version != 1 && version != CURRENT_SCHEMA_VERSION) {
|
||||
throw new IllegalArgumentException("Unsupported schema_version " + version);
|
||||
}
|
||||
if (version == CURRENT_SCHEMA_VERSION) {
|
||||
for (String field : object.keySet()) {
|
||||
if (!V2_FIELDS.contains(field)) {
|
||||
throw new IllegalArgumentException("Unknown symbol field '" + field + "'");
|
||||
}
|
||||
}
|
||||
} else if (object.has("semantics")) {
|
||||
throw new IllegalArgumentException("Schema v1 cannot declare semantics; use schema_version 2");
|
||||
}
|
||||
String name = requiredString(object, "name");
|
||||
String description = requiredString(object, "description");
|
||||
ResourceLocation category = requiredResourceLocation(object, "category",
|
||||
version == CURRENT_SCHEMA_VERSION);
|
||||
Optional<SymbolSemantics> semantics = version == CURRENT_SCHEMA_VERSION
|
||||
? Optional.of(SymbolSemantics.parse(object.get("semantics")))
|
||||
: Optional.empty();
|
||||
return new SymbolDefinition(version, name, description, category, semantics);
|
||||
}
|
||||
|
||||
public SymbolDisplayMetadata display(ResourceLocation id) {
|
||||
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true);
|
||||
}
|
||||
|
||||
private static DataResult<SymbolDefinition> validate(SymbolDefinition definition) {
|
||||
return definition.schemaVersion == CURRENT_SCHEMA_VERSION
|
||||
? DataResult.success(definition)
|
||||
: DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion);
|
||||
static String requiredString(JsonObject object, String field) {
|
||||
JsonElement element = object.get(field);
|
||||
if (element == null || !element.isJsonPrimitive()
|
||||
|| !element.getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException("'" + field + "' must be a string");
|
||||
}
|
||||
return element.getAsString();
|
||||
}
|
||||
|
||||
static ResourceLocation requiredResourceLocation(JsonObject object, String field) {
|
||||
return requiredResourceLocation(object, field, true);
|
||||
}
|
||||
|
||||
private static ResourceLocation requiredResourceLocation(JsonObject object, String field, boolean canonical) {
|
||||
String value = requiredString(object, field);
|
||||
ResourceLocation id = ResourceLocation.tryParse(value);
|
||||
if (id == null || (canonical && !id.toString().equals(value))) {
|
||||
throw new IllegalArgumentException("'" + field + "' must be "
|
||||
+ (canonical ? "a canonical namespaced ID" : "a resource location") + ": " + value);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private static int requiredInteger(JsonObject object, String field) {
|
||||
JsonElement element = object.get(field);
|
||||
if (element == null || !element.isJsonPrimitive()) {
|
||||
throw new IllegalArgumentException("'" + field + "' must be an integer");
|
||||
}
|
||||
JsonPrimitive primitive = element.getAsJsonPrimitive();
|
||||
if (!primitive.isNumber() || !primitive.getAsString().matches("-?\\d+")) {
|
||||
throw new IllegalArgumentException("'" + field + "' must be an integer");
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(primitive.getAsString());
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException("'" + field + "' is outside the integer range", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTranslationKey(String value, String field) {
|
||||
if (!TRANSLATION_KEY.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("Invalid '" + field + "' translation key: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Bounded grammar role and value, retained by the server catalog only. */
|
||||
public record SymbolSemantics(Role role, Value value) {
|
||||
public SymbolSemantics {
|
||||
Objects.requireNonNull(role, "role");
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (!role.accepts(value)) {
|
||||
throw new IllegalArgumentException("Invalid semantics.value for role '" + role.id
|
||||
+ "': " + value + "; expected " + role.expected());
|
||||
}
|
||||
}
|
||||
|
||||
public static SymbolSemantics parse(JsonElement json) {
|
||||
if (json == null || !json.isJsonObject()) {
|
||||
throw new IllegalArgumentException("'semantics' must be an object");
|
||||
}
|
||||
JsonObject object = json.getAsJsonObject();
|
||||
for (String field : object.keySet()) {
|
||||
if (!field.equals("role") && !field.equals("value")) {
|
||||
throw new IllegalArgumentException("Unknown semantics field '" + field + "'");
|
||||
}
|
||||
}
|
||||
String roleId = SymbolDefinition.requiredString(object, "role");
|
||||
Role role = Arrays.stream(Role.values()).filter(candidate -> candidate.id.equals(roleId))
|
||||
.findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown semantic role '" + roleId + "'"));
|
||||
JsonElement raw = object.get("value");
|
||||
if (raw == null || !raw.isJsonPrimitive()) {
|
||||
throw new IllegalArgumentException("'semantics.value' is required");
|
||||
}
|
||||
JsonPrimitive primitive = raw.getAsJsonPrimitive();
|
||||
Value value;
|
||||
if (role.kind == Kind.RESOURCE) {
|
||||
value = new ResourceValue(SymbolDefinition.requiredResourceLocation(object, "value"));
|
||||
} else if (role.kind == Kind.NUMBER) {
|
||||
if (!primitive.isNumber()) {
|
||||
throw new IllegalArgumentException("'semantics.value' for " + role.id + " must be a number");
|
||||
}
|
||||
value = new NumberValue(primitive.getAsDouble());
|
||||
} else {
|
||||
value = new ChoiceValue(SymbolDefinition.requiredString(object, "value"));
|
||||
}
|
||||
return new SymbolSemantics(role, value);
|
||||
}
|
||||
|
||||
public sealed interface Value permits ResourceValue, ChoiceValue, NumberValue {}
|
||||
|
||||
public record ResourceValue(ResourceLocation id) implements Value {
|
||||
public ResourceValue { Objects.requireNonNull(id, "id"); }
|
||||
}
|
||||
|
||||
public record ChoiceValue(String id) implements Value {
|
||||
public ChoiceValue { Objects.requireNonNull(id, "id"); }
|
||||
}
|
||||
|
||||
public record NumberValue(double amount) implements Value {
|
||||
public NumberValue {
|
||||
if (!Double.isFinite(amount)) {
|
||||
throw new IllegalArgumentException("Semantic numeric value must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Kind { RESOURCE, CHOICE, NUMBER }
|
||||
|
||||
public enum Role {
|
||||
TERRAIN("terrain", Kind.RESOURCE),
|
||||
BIOME_CONTROLLER("biome_controller", Kind.CHOICE,
|
||||
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
|
||||
BIOME("biome", Kind.RESOURCE),
|
||||
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
|
||||
WEATHER("weather", Kind.CHOICE,
|
||||
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
|
||||
CELESTIAL("celestial", Kind.CHOICE, "normal_time"),
|
||||
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
|
||||
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
||||
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
||||
FEATURE("feature", Kind.RESOURCE);
|
||||
|
||||
private final String id;
|
||||
private final Kind kind;
|
||||
private final Set<String> choices;
|
||||
|
||||
Role(String id, Kind kind, String... choices) {
|
||||
this.id = id;
|
||||
this.kind = kind;
|
||||
this.choices = Set.of(choices);
|
||||
}
|
||||
|
||||
public String id() { return id; }
|
||||
|
||||
private boolean accepts(Value value) {
|
||||
if (kind == Kind.RESOURCE) return value instanceof ResourceValue;
|
||||
if (kind == Kind.CHOICE) return value instanceof ChoiceValue choice && choices.contains(choice.id());
|
||||
if (!(value instanceof NumberValue number)) return false;
|
||||
return switch (this) {
|
||||
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
|
||||
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private String expected() {
|
||||
return switch (kind) {
|
||||
case RESOURCE -> "a canonical namespaced ID";
|
||||
case CHOICE -> "one of " + choices.stream().sorted().toList();
|
||||
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
|
||||
: "a finite number in [0, 360) degrees";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.biome.native",
|
||||
"description": "symbol.rivencraft.biome.native.description",
|
||||
"category": "rivencraft:biome"
|
||||
"category": "rivencraft:biome",
|
||||
"semantics": { "role": "biome_controller", "value": "native" }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.celestial.normal_time",
|
||||
"description": "symbol.rivencraft.celestial.normal_time.description",
|
||||
"category": "rivencraft:celestial"
|
||||
"category": "rivencraft:celestial",
|
||||
"semantics": { "role": "celestial", "value": "normal_time" }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.caves",
|
||||
"description": "symbol.rivencraft.feature.caves.description",
|
||||
"category": "rivencraft:feature"
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:caves" }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.lighting.normal",
|
||||
"description": "symbol.rivencraft.lighting.normal.description",
|
||||
"category": "rivencraft:lighting"
|
||||
"category": "rivencraft:lighting",
|
||||
"semantics": { "role": "lighting", "value": "normal" }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.terrain.standard",
|
||||
"description": "symbol.rivencraft.terrain.standard.description",
|
||||
"category": "rivencraft:terrain"
|
||||
"category": "rivencraft:terrain",
|
||||
"semantics": { "role": "terrain", "value": "rivencraft:standard" }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.weather.normal",
|
||||
"description": "symbol.rivencraft.weather.normal.description",
|
||||
"category": "rivencraft:weather"
|
||||
"category": "rivencraft:weather",
|
||||
"semantics": { "role": "weather", "value": "normal" }
|
||||
}
|
||||
Reference in new issue
Block a user