Update from Git Manager GUI

This commit is contained in:
2026-03-24 08:55:02 +01:00
parent 957b5a4d9e
commit 6fe5e59964
10 changed files with 173 additions and 52 deletions

View File

@@ -13,6 +13,7 @@ import de.ticketsystem.manager.FaqManager;
import de.ticketsystem.manager.LanguageManager; import de.ticketsystem.manager.LanguageManager;
import de.ticketsystem.manager.TicketManager; import de.ticketsystem.manager.TicketManager;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketPriority;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -53,6 +54,7 @@ public class TicketPlugin extends JavaPlugin {
// ── Sprachdatei laden (lang.yml) ────────────────────────────────── // ── Sprachdatei laden (lang.yml) ──────────────────────────────────
// Muss VOR allen anderen Managern geschehen, da diese lang() nutzen. // Muss VOR allen anderen Managern geschehen, da diese lang() nutzen.
languageManager = new LanguageManager(this); languageManager = new LanguageManager(this);
TicketPriority.reloadLocalizedNames(this);
// ── BungeeCord Plugin-Messaging-Kanäle registrieren ─────────────── // ── BungeeCord Plugin-Messaging-Kanäle registrieren ───────────────
getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.BUNGEE_CHANNEL); getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.BUNGEE_CHANNEL);
@@ -67,8 +69,8 @@ public class TicketPlugin extends JavaPlugin {
getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!"); getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!");
} }
// BungeeCord-Hinweis nur bei deaktiviertem Feature ausgeben // BungeeCord-Hinweis nur bei deaktiviertem Feature und aktivem Debug ausgeben
if (!getConfig().getBoolean("bungeecord", false)) { if (getConfig().getBoolean("debug", false) && !getConfig().getBoolean("bungeecord", false)) {
getLogger().info("[BungeeCord] Cross-Server-Features deaktiviert. Setze 'bungeecord: true' um sie zu aktivieren."); getLogger().info("[BungeeCord] Cross-Server-Features deaktiviert. Setze 'bungeecord: true' um sie zu aktivieren.");
} }
@@ -267,6 +269,7 @@ public class TicketPlugin extends JavaPlugin {
getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!"); getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!");
} }
debug = getConfig().getBoolean("debug", false); debug = getConfig().getBoolean("debug", false);
TicketPriority.reloadLocalizedNames(this);
} }
public static TicketPlugin getInstance() { return instance; } public static TicketPlugin getInstance() { return instance; }

View File

@@ -133,6 +133,8 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true); boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true);
boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true); boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true);
boolean allowPlayersPrio = plugin.getConfig().getBoolean("allow-players-to-set-priority", false);
boolean isTeam = player.hasPermission("ticket.support") || player.hasPermission("ticket.admin");
if (args.length >= 3) { if (args.length >= 3) {
if (categoriesOn) { if (categoriesOn) {
@@ -140,15 +142,15 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (parsedCat != null) { if (parsedCat != null) {
category = parsedCat; category = parsedCat;
msgStart = 2; msgStart = 2;
if (prioritiesOn && args.length >= 4) { if (prioritiesOn && args.length >= 4 && (allowPlayersPrio || isTeam)) {
TicketPriority parsedPrio = parsePriority(args[2]); TicketPriority parsedPrio = parsePriority(args[2]);
if (parsedPrio != null) { priority = parsedPrio; msgStart = 3; } if (parsedPrio != null) { priority = parsedPrio; msgStart = 3; }
} }
} else if (prioritiesOn) { } else if (prioritiesOn && (allowPlayersPrio || isTeam)) {
TicketPriority parsedPrio = parsePriority(args[1]); TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) { priority = parsedPrio; msgStart = 2; } if (parsedPrio != null) { priority = parsedPrio; msgStart = 2; }
} }
} else if (prioritiesOn) { } else if (prioritiesOn && (allowPlayersPrio || isTeam)) {
TicketPriority parsedPrio = parsePriority(args[1]); TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) { priority = parsedPrio; msgStart = 2; } if (parsedPrio != null) { priority = parsedPrio; msgStart = 2; }
} }
@@ -856,6 +858,26 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
}; };
} }
private List<String> getPriorityInputsForTab(String input) {
List<String> options = new ArrayList<>();
String lowerInput = input == null ? "" : input.toLowerCase();
if (plugin.lang().acceptsGerman()) {
options.addAll(List.of("niedrig", "normal", "hoch", "dringend"));
}
if (plugin.lang().acceptsEnglish()) {
options.addAll(List.of("low", "normal", "high", "urgent"));
}
List<String> filtered = new ArrayList<>();
for (String option : options) {
if (option.startsWith(lowerInput) && !filtered.contains(option)) {
filtered.add(option);
}
}
return filtered;
}
// ── Tab-Completion ──────────────────────────────────────────────────── // ── Tab-Completion ────────────────────────────────────────────────────
@Override @Override
@@ -863,7 +885,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
List<String> completions = new ArrayList<>(); List<String> completions = new ArrayList<>();
if (!(sender instanceof Player player)) return completions; if (!(sender instanceof Player player)) return completions;
// Immer direkt vom LanguageManager lesen kein Cache, immer aktuell nach reload // Nur die in der Config eingestellte Sprache verwenden
final boolean useDe = plugin.lang().acceptsGerman(); final boolean useDe = plugin.lang().acceptsGerman();
final boolean useEn = plugin.lang().acceptsEnglish(); final boolean useEn = plugin.lang().acceptsEnglish();
@@ -875,24 +897,25 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
if (useEn) subs.addAll(List.of("claim", "close")); if (useEn) subs.addAll(List.of("claim", "close"));
if (useDe) subs.addAll(List.of("übernehmen", "schließen")); if (useDe) subs.addAll(List.of("übernehmen", "schließen"));
}
if (plugin.getConfig().getBoolean("rating-enabled", true)) { if (useEn) subs.addAll(List.of("forward", "reload", "stats", "archive", "blacklist"));
if (useEn) subs.add("rate"); if (useDe) subs.addAll(List.of("weiterleiten", "neuladen", "statistik", "archiv", "blacklist"));
if (useDe) subs.add("bewerten");
}
if (player.hasPermission("ticket.admin")) {
if (useEn) subs.addAll(List.of("forward", "reload", "stats", "archive",
"migrate", "export", "import", "blacklist"));
if (useDe) subs.addAll(List.of("weiterleiten", "neuladen", "statistik",
"archivieren", "migrieren", "exportieren", "importieren", "sperrliste"));
}
if ((player.hasPermission("ticket.support") || player.hasPermission("ticket.admin"))
&& plugin.getConfig().getBoolean("priorities-enabled", true)) {
if (useEn) subs.add("setpriority"); if (useEn) subs.add("setpriority");
if (useDe) subs.add("priorität"); if (useDe) subs.add("priorität");
} }
if (player.hasPermission("ticket.admin")) {
if (useEn) subs.addAll(List.of("faq"));
if (useDe) subs.addAll(List.of("faq"));
}
if (useEn) subs.add("rate");
if (useDe) subs.add("bewerten");
for (String s : subs) for (String s : subs)
if (s.startsWith(args[0].toLowerCase())) completions.add(s); if (s.toLowerCase().startsWith(args[0].toLowerCase())) completions.add(s);
return completions;
} else if (args.length == 2 && normalize(args[0]).equals("faq")) { } else if (args.length == 2 && normalize(args[0]).equals("faq")) {
List<String> faqSubs = new ArrayList<>(); List<String> faqSubs = new ArrayList<>();
@@ -912,22 +935,39 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
for (FaqEntry e : plugin.getFaqManager().getAll()) for (FaqEntry e : plugin.getFaqManager().getAll())
completions.add(String.valueOf(e.getId())); completions.add(String.valueOf(e.getId()));
} else if (args.length == 2 && normalize(args[0]).equals("create") } else if (args.length == 2 && normalize(args[0]).equals("create")) {
&& plugin.getConfig().getBoolean("categories-enabled", true)) { boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true);
for (ConfigCategory c : plugin.getCategoryManager().getAll()) boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true);
if (c.getKey().startsWith(args[1].toLowerCase())) completions.add(c.getKey());
if (plugin.getConfig().getBoolean("priorities-enabled", true)) // Wenn Kategorien aktiviert: zeige nur Kategorien-Anzeigenamen bei args[1]
for (String p : List.of("low", "normal", "high", "urgent")) if (categoriesOn) {
if (p.startsWith(args[1].toLowerCase())) completions.add(p); completions.addAll(plugin.getCategoryManager().getDisplayNamesForTabComplete(args[1]));
} else if (prioritiesOn) {
// Wenn nur Prioritäten aktiviert (keine Kategorien): zeige Prioritäten bei args[1]
boolean allowPlayersPrio = plugin.getConfig().getBoolean("allow-players-to-set-priority", false);
boolean isTeam = sender instanceof Player p &&
(p.hasPermission("ticket.support") || p.hasPermission("ticket.admin"));
if (allowPlayersPrio || isTeam) {
completions.addAll(getPriorityInputsForTab(args[1]));
}
}
} else if (args.length == 3 && normalize(args[0]).equals("create") } else if (args.length == 3 && normalize(args[0]).equals("create")) {
&& plugin.getConfig().getBoolean("priorities-enabled", true)) { boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true);
for (String p : List.of("low", "normal", "high", "urgent")) boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true);
if (p.startsWith(args[2].toLowerCase())) completions.add(p);
// Wenn beide aktiviert: args[2] = Priorität
if (categoriesOn && prioritiesOn) {
boolean allowPlayersPrio = plugin.getConfig().getBoolean("allow-players-to-set-priority", false);
boolean isTeam = sender instanceof Player p &&
(p.hasPermission("ticket.support") || p.hasPermission("ticket.admin"));
if (allowPlayersPrio || isTeam) {
completions.addAll(getPriorityInputsForTab(args[2]));
}
}
} else if (args.length == 3 && normalize(args[0]).equals("setpriority")) { } else if (args.length == 3 && normalize(args[0]).equals("setpriority")) {
for (String p : List.of("low", "normal", "high", "urgent")) completions.addAll(getPriorityInputsForTab(args[2]));
if (p.startsWith(args[2].toLowerCase())) completions.add(p);
} else if (args.length == 3 && normalize(args[0]).equals("forward")) { } else if (args.length == 3 && normalize(args[0]).equals("forward")) {
for (Player p : Bukkit.getOnlinePlayers()) for (Player p : Bukkit.getOnlinePlayers())

View File

@@ -125,6 +125,14 @@ public class DatabaseManager {
config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit","2048"); config.addDataSourceProperty("prepStmtCacheSqlLimit","2048");
// HikariCP-Logs unterdrücken wenn debug: false
if (!plugin.isDebug()) {
java.util.logging.Logger.getLogger("de.ticketsystem.libs.hikari").setLevel(java.util.logging.Level.WARNING);
java.util.logging.Logger.getLogger("de.ticketsystem.libs.hikari.HikariDataSource").setLevel(java.util.logging.Level.WARNING);
java.util.logging.Logger.getLogger("de.ticketsystem.libs.hikari.pool.HikariPool").setLevel(java.util.logging.Level.WARNING);
java.util.logging.Logger.getLogger("de.ticketsystem.libs.hikari.pool.PoolBase").setLevel(java.util.logging.Level.WARNING);
}
dataSource = new HikariDataSource(config); dataSource = new HikariDataSource(config);
createTables(); createTables();
ensureColumns(); ensureColumns();

View File

@@ -7,9 +7,11 @@ import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Loads and manages ticket categories defined in config.yml under the "categories" section. * Loads and manages ticket categories defined in config.yml under the "categories" section.
@@ -88,13 +90,18 @@ public class CategoryManager {
for (String alias : aliases) { for (String alias : aliases) {
aliasMap.put(alias.toLowerCase(), key.toLowerCase()); aliasMap.put(alias.toLowerCase(), key.toLowerCase());
} }
// Der Anzeigename soll ebenfalls als Eingabe funktionieren (z.B. "Fehler")
aliasMap.put(name.toLowerCase(), key.toLowerCase());
} }
if (categories.isEmpty()) { if (categories.isEmpty()) {
plugin.getLogger().warning("[CategoryManager] Keine gültigen Kategorien in der config.yml gefunden. Lade Standardkategorien."); plugin.getLogger().warning("[CategoryManager] Keine gültigen Kategorien in der config.yml gefunden. Lade Standardkategorien.");
loadDefaults(); loadDefaults();
} else { } else {
plugin.getLogger().info("[CategoryManager] " + categories.size() + " Kategorie(n) geladen: " + String.join(", ", categories.keySet())); if (plugin.isDebug()) {
plugin.getLogger().info("[CategoryManager] " + categories.size() + " Kategorie(n) geladen: " + String.join(", ", categories.keySet()));
}
} }
} }
@@ -113,6 +120,7 @@ public class CategoryManager {
categories.put(key, cat); categories.put(key, cat);
aliasMap.put(key, key); aliasMap.put(key, key);
for (String alias : aliases) aliasMap.put(alias.toLowerCase(), key); for (String alias : aliases) aliasMap.put(alias.toLowerCase(), key);
aliasMap.put(name.toLowerCase(), key);
} }
// ─────────────────────────── Public API ──────────────────────────────── // ─────────────────────────── Public API ────────────────────────────────
@@ -160,6 +168,22 @@ public class CategoryManager {
return String.join(", ", categories.keySet()); return String.join(", ", categories.keySet());
} }
/**
* Gibt für Tab-Completion nur die Kategorie-Anzeigenamen in Config-Reihenfolge zurück.
* Beispiel: "all" -> ["allgemein"]
*/
public List<String> getDisplayNamesForTabComplete(String input) {
String lowerInput = input == null ? "" : input.toLowerCase();
Set<String> results = new LinkedHashSet<>();
for (ConfigCategory category : categories.values()) {
String displayName = category.getName().toLowerCase();
if (displayName.startsWith(lowerInput)) {
results.add(displayName);
}
}
return new ArrayList<>(results);
}
/** /**
* Reloads categories from the (already reloaded) config. * Reloads categories from the (already reloaded) config.
* Call this after plugin.reloadConfig(). * Call this after plugin.reloadConfig().

View File

@@ -195,9 +195,11 @@ public class LanguageManager {
prefix = color(lang.getString("prefix", "&8[&6Ticket&8] &r")); prefix = color(lang.getString("prefix", "&8[&6Ticket&8] &r"));
cmdNames = buildCmdNames(); cmdNames = buildCmdNames();
plugin.getLogger().info("[LanguageManager] Geladen: " + fileName if (plugin.isDebug()) {
+ " | language=" + activeLang plugin.getLogger().info("[LanguageManager] Geladen: " + fileName
+ " | Befehle: " + describeMode()); + " | language=" + activeLang
+ " | Befehle: " + describeMode());
}
} }
// ── Befehlsnamen ───────────────────────────────────────────────────────── // ── Befehlsnamen ─────────────────────────────────────────────────────────

View File

@@ -1,21 +1,24 @@
package de.ticketsystem.model; package de.ticketsystem.model;
import de.ticketsystem.TicketPlugin;
import org.bukkit.Material; import org.bukkit.Material;
public enum TicketPriority { public enum TicketPriority {
LOW ("Niedrig", "§a", Material.GREEN_WOOL), LOW ("priorities.low", "§a", Material.GREEN_WOOL),
NORMAL ("Normal", "§e", Material.YELLOW_WOOL), NORMAL ("priorities.normal", "§e", Material.YELLOW_WOOL),
HIGH ("Hoch", "§6", Material.ORANGE_WOOL), HIGH ("priorities.high", "§6", Material.ORANGE_WOOL),
URGENT ("Dringend","§c", Material.RED_WOOL); URGENT ("priorities.urgent", "§c", Material.RED_WOOL);
private final String displayName; private final String langKey;
private final String color; private final String color;
private final Material guiMaterial; private final Material guiMaterial;
private String displayName;
TicketPriority(String displayName, String color, Material guiMaterial) { TicketPriority(String langKey, String color, Material guiMaterial) {
this.displayName = displayName; this.langKey = langKey;
this.color = color; this.color = color;
this.guiMaterial = guiMaterial; this.guiMaterial = guiMaterial;
this.displayName = langKey;
} }
public String getDisplayName() { return displayName; } public String getDisplayName() { return displayName; }
@@ -28,4 +31,20 @@ public enum TicketPriority {
try { return valueOf(s.toUpperCase()); } try { return valueOf(s.toUpperCase()); }
catch (IllegalArgumentException e) { return NORMAL; } catch (IllegalArgumentException e) { return NORMAL; }
} }
/**
* Lädt die lokalisierten Namen aus der Sprachdatei neu.
* Sollte beim Laden des Plugins / beim Reload aufgerufen werden.
*/
public static void reloadLocalizedNames(TicketPlugin plugin) {
if (plugin == null) return;
for (TicketPriority prio : values()) {
prio.displayName = plugin.lang().get(prio.langKey);
if (prio.displayName == null || prio.displayName.isEmpty()) {
// Fallback wenn Schlüssel nicht gefunden
prio.displayName = prio.langKey.substring(prio.langKey.indexOf('.') + 1);
}
}
}
} }

View File

@@ -108,6 +108,11 @@ categories-enabled: true
# Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority <id> <low|normal|high|urgent> # Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority <id> <low|normal|high|urgent>
priorities-enabled: true priorities-enabled: true
# Dürfen normale Spieler beim Erstellen eine Priorität setzen?
# false = Nur Admins/Supporter können Prioritäten setzen (ticket.support, ticket.admin)
# true = Spieler können beim /ticket create dabei Prioritäten angeben
allow-players-to-set-priority: false
# Bewertungs-System (true = aktiviert) # Bewertungs-System (true = aktiviert)
# Spieler können nach dem Schließen den Support bewerten: /ticket rate <id> good|bad # Spieler können nach dem Schließen den Support bewerten: /ticket rate <id> good|bad
# Ergebnisse sind in /ticket stats sichtbar # Ergebnisse sind in /ticket stats sichtbar
@@ -135,7 +140,7 @@ categories:
- "general" - "general"
- "default" - "default"
bug: bug:
name: "Bug" name: "Fehler"
color: "&c" color: "&c"
material: "REDSTONE" material: "REDSTONE"
aliases: aliases:

View File

@@ -82,8 +82,8 @@ notify:
# ============================================================ # ============================================================
create: create:
usage: "&cBenutzung: {cmd_create} [Kategorie] [Priorität] <Beschreibung>" usage: "&cBenutzung: {cmd_create} [Kategorie] [Priorität] <Beschreibung>"
categories-hint: "&7Kategorien: &ebug&7, &efrage&7, &ebeschwerde&7, &esonstiges&7, &eallgemein" categories-hint: "&7Kategorien: &efehler&7, &efrage&7, &ebeschwerde&7, &esonstiges&7, &eallgemein"
priorities-hint: "&7Prioritäten: &alow&7, &enormal&7, &6high&7, &curgent" priorities-hint: "&7Prioritäten: &aniedrig&7, &enormal&7, &6hoch&7, &cdringend"
max-tickets: "&cDu hast bereits &e{max} &coffene Ticket(s). Bitte warte, bis dein Ticket bearbeitet wurde." max-tickets: "&cDu hast bereits &e{max} &coffene Ticket(s). Bitte warte, bis dein Ticket bearbeitet wurde."
no-description: "&cBitte gib eine Beschreibung für dein Ticket an." no-description: "&cBitte gib eine Beschreibung für dein Ticket an."
too-long: "&cDeine Beschreibung ist zu lang! Maximal {max} Zeichen." too-long: "&cDeine Beschreibung ist zu lang! Maximal {max} Zeichen."
@@ -139,11 +139,21 @@ rating:
# PRIORITÄT SETZEN # PRIORITÄT SETZEN
# ============================================================ # ============================================================
setpriority: setpriority:
usage: "&cBenutzung: {cmd_setpriority} <ID> <low|normal|high|urgent>" usage: "&cBenutzung: {cmd_setpriority} <ID> <niedrig|normal|hoch|dringend>"
disabled: "&cDas Prioritäten-System ist deaktiviert." disabled: "&cDas Prioritäten-System ist deaktiviert."
invalid: "&cUngültige Priorität! Gültig: &alow&7, &enormal&7, &6high&7, &curgent" invalid: "&cUngültige Priorität! Gültig: &aniedrig&7, &enormal&7, &6hoch&7, &cdringend"
success: "&aPriorität von Ticket &e#{id} &awurde auf {priority} &agesetzt." success: "&aPriorität von Ticket &e#{id} &awurde auf {priority} &agesetzt."
not-found: "&cTicket &e#{id} &cwurde nicht gefunden." not-found: "&cTicket &e#{id} &cwurde nicht gefunden."
no-player-permission: "&cNur Admins und Supporter dürfen die Priorität setzen."
# ============================================================
# PRIORITÄTEN LABELS (Low, Normal, High, Urgent)
# ============================================================
priorities:
low: "Niedrig"
normal: "Normal"
high: "Hoch"
urgent: "Dringend"
# ============================================================ # ============================================================
# BLACKLIST # BLACKLIST

View File

@@ -145,6 +145,16 @@ setpriority:
invalid: "&cInvalid priority! Valid: &alow&7, &enormal&7, &6high&7, &curgent" invalid: "&cInvalid priority! Valid: &alow&7, &enormal&7, &6high&7, &curgent"
success: "&aPriority of ticket &e#{id} &ahas been set to {priority}&a." success: "&aPriority of ticket &e#{id} &ahas been set to {priority}&a."
not-found: "&cTicket &e#{id} &cwas not found." not-found: "&cTicket &e#{id} &cwas not found."
no-player-permission: "&cOnly admins and supporters are allowed to set priority."
# ============================================================
# PRIORITY LABELS (Low, Normal, High, Urgent)
# ============================================================
priorities:
low: "Low"
normal: "Normal"
high: "High"
urgent: "Urgent"
# ============================================================ # ============================================================
# BLACKLIST # BLACKLIST

View File

@@ -1,5 +1,5 @@
name: TicketSystem name: TicketSystem
version: 1.0.8 version: 1.0.9
main: de.ticketsystem.TicketPlugin main: de.ticketsystem.TicketPlugin
api-version: 1.20 api-version: 1.20
author: M_Viper author: M_Viper