Mirror von
https://github.com/GeyserMC/Geyser.git
synchronisiert 2024-11-19 14:30:17 +01:00
Improvements to MessageTranslator (#3803)
* Renames for clarity and refactor convertToJavaMessage * Bump adventure, velociy. Require CharacterAndFormat in MessageTranslator * Fix deprecations related to DummyLegacyHoverEventSerializer * Patch serialization of ScoreComponent until Adventure 1.15.0
Dieser Commit ist enthalten in:
Ursprung
706d1b9627
Commit
661a9b4741
@ -75,7 +75,7 @@ public class AnvilContainer extends Container {
|
|||||||
|
|
||||||
String originalName = ItemUtils.getCustomName(getInput().getNbt());
|
String originalName = ItemUtils.getCustomName(getInput().getNbt());
|
||||||
|
|
||||||
String plainOriginalName = MessageTranslator.convertToPlainText(originalName, session.locale());
|
String plainOriginalName = MessageTranslator.convertToPlainTextLenient(originalName, session.locale());
|
||||||
String plainNewName = MessageTranslator.convertToPlainText(rename);
|
String plainNewName = MessageTranslator.convertToPlainText(rename);
|
||||||
if (!plainOriginalName.equals(plainNewName)) {
|
if (!plainOriginalName.equals(plainNewName)) {
|
||||||
// Strip out formatting since Java Edition does not allow it
|
// Strip out formatting since Java Edition does not allow it
|
||||||
|
@ -118,7 +118,7 @@ public class AnvilInventoryUpdater extends InventoryUpdater {
|
|||||||
|
|
||||||
// Changing the item in the input slot resets the name field on Bedrock, but
|
// Changing the item in the input slot resets the name field on Bedrock, but
|
||||||
// does not result in a FilterTextPacket
|
// does not result in a FilterTextPacket
|
||||||
String originalName = MessageTranslator.convertToPlainText(ItemUtils.getCustomName(input.getNbt()), session.locale());
|
String originalName = MessageTranslator.convertToPlainTextLenient(ItemUtils.getCustomName(input.getNbt()), session.locale());
|
||||||
ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(originalName);
|
ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(originalName);
|
||||||
session.sendDownstreamPacket(renameItemPacket);
|
session.sendDownstreamPacket(renameItemPacket);
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ package org.geysermc.geyser.text;
|
|||||||
import net.kyori.adventure.key.Key;
|
import net.kyori.adventure.key.Key;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.event.HoverEvent;
|
import net.kyori.adventure.text.event.HoverEvent;
|
||||||
import net.kyori.adventure.text.serializer.gson.LegacyHoverEventSerializer;
|
import net.kyori.adventure.text.serializer.json.LegacyHoverEventSerializer;
|
||||||
import net.kyori.adventure.util.Codec;
|
import net.kyori.adventure.util.Codec;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
@ -40,9 +40,9 @@ public final class DummyLegacyHoverEventSerializer implements LegacyHoverEventSe
|
|||||||
private final HoverEvent.ShowItem dummyShowItem;
|
private final HoverEvent.ShowItem dummyShowItem;
|
||||||
|
|
||||||
public DummyLegacyHoverEventSerializer() {
|
public DummyLegacyHoverEventSerializer() {
|
||||||
dummyShowEntity = HoverEvent.ShowEntity.of(Key.key("geysermc", "dummyshowitem"),
|
dummyShowEntity = HoverEvent.ShowEntity.showEntity(Key.key("geysermc", "dummyshowitem"),
|
||||||
UUID.nameUUIDFromBytes("entitiesareprettyneat".getBytes(StandardCharsets.UTF_8)));
|
UUID.nameUUIDFromBytes("entitiesareprettyneat".getBytes(StandardCharsets.UTF_8)));
|
||||||
dummyShowItem = HoverEvent.ShowItem.of(Key.key("geysermc", "dummyshowentity"), 0);
|
dummyShowItem = HoverEvent.ShowItem.showItem(Key.key("geysermc", "dummyshowentity"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -43,7 +43,7 @@ public class CommandBlockBlockEntityTranslator extends BlockEntityTranslator imp
|
|||||||
// Java and Bedrock values
|
// Java and Bedrock values
|
||||||
builder.put("conditionMet", ((ByteTag) tag.get("conditionMet")).getValue());
|
builder.put("conditionMet", ((ByteTag) tag.get("conditionMet")).getValue());
|
||||||
builder.put("auto", ((ByteTag) tag.get("auto")).getValue());
|
builder.put("auto", ((ByteTag) tag.get("auto")).getValue());
|
||||||
builder.put("CustomName", MessageTranslator.convertMessage(((StringTag) tag.get("CustomName")).getValue()));
|
builder.put("CustomName", MessageTranslator.convertJsonMessage(((StringTag) tag.get("CustomName")).getValue()));
|
||||||
builder.put("powered", ((ByteTag) tag.get("powered")).getValue());
|
builder.put("powered", ((ByteTag) tag.get("powered")).getValue());
|
||||||
builder.put("Command", ((StringTag) tag.get("Command")).getValue());
|
builder.put("Command", ((StringTag) tag.get("Command")).getValue());
|
||||||
builder.put("SuccessCount", ((IntTag) tag.get("SuccessCount")).getValue());
|
builder.put("SuccessCount", ((IntTag) tag.get("SuccessCount")).getValue());
|
||||||
|
@ -28,7 +28,9 @@ package org.geysermc.geyser.translator.text;
|
|||||||
import com.github.steveice10.mc.protocol.data.DefaultComponentSerializer;
|
import com.github.steveice10.mc.protocol.data.DefaultComponentSerializer;
|
||||||
import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamColor;
|
import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamColor;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.ScoreComponent;
|
||||||
import net.kyori.adventure.text.TranslatableComponent;
|
import net.kyori.adventure.text.TranslatableComponent;
|
||||||
|
import net.kyori.adventure.text.flattener.ComponentFlattener;
|
||||||
import net.kyori.adventure.text.format.TextColor;
|
import net.kyori.adventure.text.format.TextColor;
|
||||||
import net.kyori.adventure.text.renderer.TranslatableComponentRenderer;
|
import net.kyori.adventure.text.renderer.TranslatableComponentRenderer;
|
||||||
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
||||||
@ -50,8 +52,8 @@ public class MessageTranslator {
|
|||||||
// Possible TODO: replace the legacy hover event serializer with an empty one since we have no use for hover events
|
// Possible TODO: replace the legacy hover event serializer with an empty one since we have no use for hover events
|
||||||
private static final GsonComponentSerializer GSON_SERIALIZER;
|
private static final GsonComponentSerializer GSON_SERIALIZER;
|
||||||
|
|
||||||
private static final LegacyComponentSerializer LEGACY_SERIALIZER;
|
private static final LegacyComponentSerializer BEDROCK_SERIALIZER;
|
||||||
private static final String ALL_COLORS;
|
private static final String BEDROCK_COLORS;
|
||||||
|
|
||||||
// Store team colors for player names
|
// Store team colors for player names
|
||||||
private static final Map<TeamColor, String> TEAM_COLORS = new EnumMap<>(TeamColor.class);
|
private static final Map<TeamColor, String> TEAM_COLORS = new EnumMap<>(TeamColor.class);
|
||||||
@ -99,11 +101,7 @@ public class MessageTranslator {
|
|||||||
// Tell MCProtocolLib to use this serializer, too.
|
// Tell MCProtocolLib to use this serializer, too.
|
||||||
DefaultComponentSerializer.set(GSON_SERIALIZER);
|
DefaultComponentSerializer.set(GSON_SERIALIZER);
|
||||||
|
|
||||||
LegacyComponentSerializer legacySerializer;
|
// Customize the formatting characters of our legacy serializer for bedrock edition
|
||||||
String allColors;
|
|
||||||
try {
|
|
||||||
Class.forName("net.kyori.adventure.text.serializer.legacy.CharacterAndFormat");
|
|
||||||
|
|
||||||
List<CharacterAndFormat> formats = new ArrayList<>(CharacterAndFormat.defaults());
|
List<CharacterAndFormat> formats = new ArrayList<>(CharacterAndFormat.defaults());
|
||||||
// The following two do not yet exist on Bedrock - https://bugs.mojang.com/browse/MCPE-41729
|
// The following two do not yet exist on Bedrock - https://bugs.mojang.com/browse/MCPE-41729
|
||||||
formats.remove(CharacterAndFormat.STRIKETHROUGH);
|
formats.remove(CharacterAndFormat.STRIKETHROUGH);
|
||||||
@ -122,24 +120,24 @@ public class MessageTranslator {
|
|||||||
formats.add(CharacterAndFormat.characterAndFormat('t', TextColor.color(33, 73, 123))); // Lapis
|
formats.add(CharacterAndFormat.characterAndFormat('t', TextColor.color(33, 73, 123))); // Lapis
|
||||||
formats.add(CharacterAndFormat.characterAndFormat('u', TextColor.color(154, 92, 198))); // Amethyst
|
formats.add(CharacterAndFormat.characterAndFormat('u', TextColor.color(154, 92, 198))); // Amethyst
|
||||||
|
|
||||||
legacySerializer = LegacyComponentSerializer.legacySection().toBuilder()
|
// Can be removed once Adventure 1.15.0 is released (see https://github.com/KyoriPowered/adventure/pull/954)
|
||||||
.formats(formats)
|
ComponentFlattener flattener = ComponentFlattener.basic().toBuilder()
|
||||||
|
.mapper(ScoreComponent.class, component -> "")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
BEDROCK_SERIALIZER = LegacyComponentSerializer.legacySection().toBuilder()
|
||||||
|
.formats(formats)
|
||||||
|
.flattener(flattener)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// cache all the legacy character codes
|
||||||
StringBuilder colorBuilder = new StringBuilder();
|
StringBuilder colorBuilder = new StringBuilder();
|
||||||
for (CharacterAndFormat format : formats) {
|
for (CharacterAndFormat format : formats) {
|
||||||
if (format.format() instanceof TextColor) {
|
if (format.format() instanceof TextColor) {
|
||||||
colorBuilder.append(format.character());
|
colorBuilder.append(format.character());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allColors = colorBuilder.toString();
|
BEDROCK_COLORS = colorBuilder.toString();
|
||||||
} catch (ClassNotFoundException ignored) {
|
|
||||||
// Velocity doesn't have this yet.
|
|
||||||
legacySerializer = LegacyComponentSerializer.legacySection();
|
|
||||||
allColors = "0123456789abcdef";
|
|
||||||
}
|
|
||||||
LEGACY_SERIALIZER = legacySerializer;
|
|
||||||
ALL_COLORS = allColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -154,7 +152,7 @@ public class MessageTranslator {
|
|||||||
// Translate any components that require it
|
// Translate any components that require it
|
||||||
message = RENDERER.render(message, locale);
|
message = RENDERER.render(message, locale);
|
||||||
|
|
||||||
String legacy = LEGACY_SERIALIZER.serialize(message);
|
String legacy = BEDROCK_SERIALIZER.serialize(message);
|
||||||
|
|
||||||
StringBuilder finalLegacy = new StringBuilder();
|
StringBuilder finalLegacy = new StringBuilder();
|
||||||
char[] legacyChars = legacy.toCharArray();
|
char[] legacyChars = legacy.toCharArray();
|
||||||
@ -170,7 +168,7 @@ public class MessageTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
char next = legacyChars[++i];
|
char next = legacyChars[++i];
|
||||||
if (ALL_COLORS.indexOf(next) != -1) {
|
if (BEDROCK_COLORS.indexOf(next) != -1) {
|
||||||
// Append this color code, as well as a necessary reset code
|
// Append this color code, as well as a necessary reset code
|
||||||
if (!lastFormatReset) {
|
if (!lastFormatReset) {
|
||||||
finalLegacy.append(RESET);
|
finalLegacy.append(RESET);
|
||||||
@ -189,12 +187,12 @@ public class MessageTranslator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String convertMessage(String message, String locale) {
|
public static String convertJsonMessage(String message, String locale) {
|
||||||
return convertMessage(GSON_SERIALIZER.deserialize(message), locale);
|
return convertMessage(GSON_SERIALIZER.deserialize(message), locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String convertMessage(String message) {
|
public static String convertJsonMessage(String message) {
|
||||||
return convertMessage(message, GeyserLocale.getDefaultLocale());
|
return convertJsonMessage(message, GeyserLocale.getDefaultLocale());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String convertMessage(Component message) {
|
public static String convertMessage(Component message) {
|
||||||
@ -202,7 +200,7 @@ public class MessageTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the message is valid JSON in case it's plaintext. Works around GsonComponentSeraializer not using lenient mode.
|
* Verifies the message is valid JSON in case it's plaintext. Works around GsonComponentSerializer not using lenient mode.
|
||||||
* See https://wiki.vg/Chat for messages sent in lenient mode, and for a description on leniency.
|
* See https://wiki.vg/Chat for messages sent in lenient mode, and for a description on leniency.
|
||||||
*
|
*
|
||||||
* @param message Potentially lenient JSON message
|
* @param message Potentially lenient JSON message
|
||||||
@ -218,9 +216,10 @@ public class MessageTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return convertMessage(message, locale);
|
return convertJsonMessage(message, locale);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
String convertedMessage = convertMessage(convertToJavaMessage(message), locale);
|
// Use the default legacy serializer since message is java-legacy
|
||||||
|
String convertedMessage = convertMessage(LegacyComponentSerializer.legacySection().deserialize(message), locale);
|
||||||
|
|
||||||
// We have to do this since Adventure strips the starting reset character
|
// We have to do this since Adventure strips the starting reset character
|
||||||
if (message.startsWith(RESET) && !convertedMessage.startsWith(RESET)) {
|
if (message.startsWith(RESET) && !convertedMessage.startsWith(RESET)) {
|
||||||
@ -242,11 +241,10 @@ public class MessageTranslator {
|
|||||||
* @return The formatted JSON string
|
* @return The formatted JSON string
|
||||||
*/
|
*/
|
||||||
public static String convertToJavaMessage(String message) {
|
public static String convertToJavaMessage(String message) {
|
||||||
Component component = LegacyComponentSerializer.legacySection().deserialize(message);
|
Component component = BEDROCK_SERIALIZER.deserialize(message);
|
||||||
return GSON_SERIALIZER.serialize(component);
|
return GSON_SERIALIZER.serialize(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert legacy format message to plain text
|
* Convert legacy format message to plain text
|
||||||
*
|
*
|
||||||
@ -275,7 +273,7 @@ public class MessageTranslator {
|
|||||||
* @param locale Locale to use for translation strings
|
* @param locale Locale to use for translation strings
|
||||||
* @return The plain text of the message
|
* @return The plain text of the message
|
||||||
*/
|
*/
|
||||||
public static String convertToPlainText(String message, String locale) {
|
public static String convertToPlainTextLenient(String message, String locale) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@ import java.util.Map;
|
|||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
public class MessageTranslatorTest {
|
public class MessageTranslatorTest {
|
||||||
|
|
||||||
private Map<String, String> messages = new HashMap<>();
|
private final Map<String, String> messages = new HashMap<>();
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
@ -70,7 +70,7 @@ public class MessageTranslatorTest {
|
|||||||
@Test
|
@Test
|
||||||
public void convertMessage() {
|
public void convertMessage() {
|
||||||
for (Map.Entry<String, String> entry : messages.entrySet()) {
|
for (Map.Entry<String, String> entry : messages.entrySet()) {
|
||||||
String bedrockMessage = MessageTranslator.convertMessage(entry.getKey(), "en_US");
|
String bedrockMessage = MessageTranslator.convertJsonMessage(entry.getKey(), "en_US");
|
||||||
Assertions.assertEquals(entry.getValue(), bedrockMessage, "Translation of messages is incorrect");
|
Assertions.assertEquals(entry.getValue(), bedrockMessage, "Translation of messages is incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,13 +85,13 @@ public class MessageTranslatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void convertToPlainText() {
|
public void convertToPlainText() {
|
||||||
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainText("{\"extra\":[{\"color\":\"red\",\"text\":\"M\"},{\"color\":\"gold\",\"text\":\"a\"},{\"color\":\"yellow\",\"text\":\"n\"},{\"color\":\"green\",\"text\":\"y \"},{\"color\":\"aqua\",\"text\":\"c\"},{\"color\":\"dark_purple\",\"text\":\"o\"},{\"color\":\"red\",\"text\":\"l\"},{\"color\":\"gold\",\"text\":\"o\"},{\"color\":\"yellow\",\"text\":\"r\"},{\"color\":\"green\",\"text\":\"s \"},{\"color\":\"aqua\",\"text\":\"h\"},{\"color\":\"dark_purple\",\"text\":\"e\"},{\"color\":\"red\",\"text\":\"r\"},{\"color\":\"gold\",\"text\":\"e\"}],\"text\":\"\"}", "en_US"), "JSON message is not handled properly");
|
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainTextLenient("{\"extra\":[{\"color\":\"red\",\"text\":\"M\"},{\"color\":\"gold\",\"text\":\"a\"},{\"color\":\"yellow\",\"text\":\"n\"},{\"color\":\"green\",\"text\":\"y \"},{\"color\":\"aqua\",\"text\":\"c\"},{\"color\":\"dark_purple\",\"text\":\"o\"},{\"color\":\"red\",\"text\":\"l\"},{\"color\":\"gold\",\"text\":\"o\"},{\"color\":\"yellow\",\"text\":\"r\"},{\"color\":\"green\",\"text\":\"s \"},{\"color\":\"aqua\",\"text\":\"h\"},{\"color\":\"dark_purple\",\"text\":\"e\"},{\"color\":\"red\",\"text\":\"r\"},{\"color\":\"gold\",\"text\":\"e\"}],\"text\":\"\"}", "en_US"), "JSON message is not handled properly");
|
||||||
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainText("§cM§6a§en§ay §bc§5o§cl§6o§er§as §bh§5e§cr§6e"), "Legacy formatted message is not handled properly (Colors)");
|
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainText("§cM§6a§en§ay §bc§5o§cl§6o§er§as §bh§5e§cr§6e"), "Legacy formatted message is not handled properly (Colors)");
|
||||||
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainText("§cM§6a§en§ay §bc§5o§cl§6o§er§as §bh§5e§cr§6e", "en_US"), "Legacy formatted message is not handled properly (Colors)");
|
Assertions.assertEquals("Many colors here", MessageTranslator.convertToPlainTextLenient("§cM§6a§en§ay §bc§5o§cl§6o§er§as §bh§5e§cr§6e", "en_US"), "Legacy formatted message is not handled properly (Colors)");
|
||||||
Assertions.assertEquals("Obf Bold Strikethrough Underline Italic Reset", MessageTranslator.convertToPlainText("§kObf §lBold §mStrikethrough §nUnderline §oItalic §rReset", "en_US"), "Legacy formatted message is not handled properly (Style)");
|
Assertions.assertEquals("Obf Bold Strikethrough Underline Italic Reset", MessageTranslator.convertToPlainTextLenient("§kObf §lBold §mStrikethrough §nUnderline §oItalic §rReset", "en_US"), "Legacy formatted message is not handled properly (Style)");
|
||||||
Assertions.assertEquals("Strange", MessageTranslator.convertToPlainText("§rStrange", "en_US"), "Valid lenient JSON is not handled properly");
|
Assertions.assertEquals("Strange", MessageTranslator.convertToPlainTextLenient("§rStrange", "en_US"), "Valid lenient JSON is not handled properly");
|
||||||
Assertions.assertEquals("", MessageTranslator.convertToPlainText("", "en_US"), "Empty message is not handled properly");
|
Assertions.assertEquals("", MessageTranslator.convertToPlainTextLenient("", "en_US"), "Empty message is not handled properly");
|
||||||
Assertions.assertEquals(" ", MessageTranslator.convertToPlainText(" ", "en_US"), "Whitespace is not preserved");
|
Assertions.assertEquals(" ", MessageTranslator.convertToPlainTextLenient(" ", "en_US"), "Whitespace is not preserved");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -14,8 +14,8 @@ protocol-connection = "3.0.0.Beta1-20230718.000033-101"
|
|||||||
raknet = "1.0.0.CR1-20230703.195238-9"
|
raknet = "1.0.0.CR1-20230703.195238-9"
|
||||||
mcauthlib = "d9d773e"
|
mcauthlib = "d9d773e"
|
||||||
mcprotocollib = "1.20-1-20230607.135651-6" # Temporary hack - needs to be updated to release once publishing is fixed
|
mcprotocollib = "1.20-1-20230607.135651-6" # Temporary hack - needs to be updated to release once publishing is fixed
|
||||||
adventure = "4.14.0-20230424.215040-7"
|
adventure = "4.14.0"
|
||||||
adventure-platform = "4.1.2"
|
adventure-platform = "4.3.0"
|
||||||
junit = "5.9.2"
|
junit = "5.9.2"
|
||||||
checkerframework = "3.19.0"
|
checkerframework = "3.19.0"
|
||||||
log4j = "2.20.0"
|
log4j = "2.20.0"
|
||||||
@ -26,7 +26,7 @@ viaversion = "4.0.0"
|
|||||||
adapters = "1.9-SNAPSHOT"
|
adapters = "1.9-SNAPSHOT"
|
||||||
commodore = "2.2"
|
commodore = "2.2"
|
||||||
bungeecord = "a7c6ede"
|
bungeecord = "a7c6ede"
|
||||||
velocity = "3.0.0"
|
velocity = "3.1.1"
|
||||||
sponge = "8.0.0"
|
sponge = "8.0.0"
|
||||||
fabric-minecraft = "1.20"
|
fabric-minecraft = "1.20"
|
||||||
fabric-loader = "0.14.21"
|
fabric-loader = "0.14.21"
|
||||||
|
Laden…
In neuem Issue referenzieren
Einen Benutzer sperren