30 Commits
1.0.1 ... 1.0.9

Author SHA1 Message Date
839932873b README.md aktualisiert 2026-03-01 11:14:42 +00:00
1a8121236f README.md aktualisiert 2026-02-25 18:28:09 +00:00
46cd01c9bc Upload pom.xml via GUI 2026-02-25 18:01:43 +00:00
e5505edf2c Update from Git Manager GUI 2026-02-25 19:01:41 +01:00
bc3c2399a6 README.md aktualisiert 2026-02-24 20:44:25 +00:00
5259184e09 Upload pom.xml via GUI 2026-02-23 20:56:22 +00:00
a45e0f4731 Update from Git Manager GUI 2026-02-23 14:39:37 +01:00
33acd04c3b Upload pom.xml via GUI 2026-02-23 13:39:35 +00:00
0a547f90bf README.md aktualisiert 2026-02-23 12:07:52 +00:00
02811bafbd Update from Git Manager GUI 2026-02-23 13:06:59 +01:00
c8d4578fa6 Upload pom.xml via GUI 2026-02-23 12:06:58 +00:00
57a426a9c9 Update from Git Manager GUI 2026-02-21 22:26:13 +01:00
135d8b0fb3 Upload pom.xml via GUI 2026-02-21 21:26:12 +00:00
31c7a33cbb Upload pom.xml via GUI 2026-02-21 17:41:16 +00:00
301c0f1ce9 Update from Git Manager GUI 2026-02-21 18:41:15 +01:00
7ede377c07 Update from Git Manager GUI 2026-02-21 16:00:03 +01:00
834bd0e5e4 Upload pom.xml via GUI 2026-02-21 15:00:01 +00:00
f4bedaa288 README.md aktualisiert 2026-02-21 13:25:26 +00:00
8e7533a214 Upload pom.xml via GUI 2026-02-20 23:55:29 +00:00
b7de357e81 Update from Git Manager GUI 2026-02-21 00:55:27 +01:00
a91e17a097 README.md aktualisiert 2026-02-20 23:49:35 +00:00
12c9379797 Upload pom.xml via GUI 2026-02-20 17:38:44 +00:00
535b0aa2f3 Update from Git Manager GUI 2026-02-20 18:38:42 +01:00
d14646c5ae README.md aktualisiert 2026-02-20 16:49:53 +00:00
df6878db2f Update from Git Manager GUI 2026-02-20 12:31:38 +01:00
526cb8b442 Upload pom.xml via GUI 2026-02-20 11:31:37 +00:00
b930793c50 README.md aktualisiert 2026-02-20 11:29:55 +00:00
566941d687 README.md aktualisiert 2026-02-20 11:29:46 +00:00
dc732c1410 README.md aktualisiert 2026-02-19 22:04:21 +00:00
d7fb6940cd README.md aktualisiert 2026-02-19 21:54:48 +00:00
26 changed files with 7581 additions and 1162 deletions

384
README.md
View File

@@ -1,99 +1,343 @@
# TicketSystem # TicketSystem
[![Version](https://img.shields.io/badge/Version-1.18.x--1.21.x-green.svg)]() [![Java](https://img.shields.io/badge/Java-17+-orange.svg)]() [![Type](https://img.shields.io/badge/Type-Support-blue.svg)]() ![Version](https://img.shields.io/badge/Minecraft-1.18.x--1.21.x-green?style=for-the-badge) ![Java](https://img.shields.io/badge/Java-17+-orange?style=for-the-badge) ![Type](https://img.shields.io/badge/Type-Support-blue?style=for-the-badge)
**TicketSystem** ist das umfassende Support- und Feedback-Plugin für Minecraft-Server. Es bietet flexible Speicherung (MySQL oder Datei), automatische Archivierung, Migration, Export/Import, Statistiken, vollständige Validierung, Debug-Modus, eine übersichtliche config.yml mit Versionsprüfung und eine dynamische GUI. **TicketSystem** ist das flexible, moderne Support- und Feedback-Plugin für Minecraft-Server (Spigot/Paper 1.18.x1.21.x, Java 17+). Es bietet flexible Speicherung, automatische Backups & Migration, Export/Import, Statistiken, dynamische GUI, Kategorie- und Prioritäten-System, Bewertungs- und Kommentar-System, **FAQ-System**, Discord-Webhook und volle BungeeCord-Unterstützung.
## Features (Details) ---
- **MySQL oder Datei-Speicherung** (YAML/JSON) jederzeit umschaltbar, Migration und Backup inklusive ## Features
- **Automatische Backups & Migration** zwischen Speicherarten, Datenverlust ausgeschlossen
- **Export/Import** von Tickets (z.B. für Server-Umzüge oder Testumgebungen)
- **Statistiken & Archivierung** (inkl. automatischer Archivierung nach Zeitplan, manuelles Archivieren möglich)
- **Konfigurierbare Speicherpfade** für Daten und Archive (relativ oder absolut)
- **Vollständige Validierung** der Daten beim Laden (Fehlerausgabe im Log & Chat, fehlerhafte Tickets werden übersprungen)
- **Bessere Fehlerausgaben** für Admins im Chat und Log (inkl. Validierungs- und Speicherfehler)
- **Debug-Modus & Versionsprüfung** für Entwickler und Admins (veraltete config.yml wird erkannt)
- **Komplett anpassbar** (Nachrichten, Farben, Limits, Speicherpfade, Archiv-Intervall, Cooldowns, Rechte)
- **Unit-Tests** für die Speicher-Logik (maximale Zuverlässigkeit)
- **Dynamische GUI**: Die Ticket-GUI passt sich automatisch der Ticketanzahl an (bis zu 54 Tickets pro Seite, Blättern bei Bedarf)
- **Performance**: Optimiert für große Server, alle Operationen laufen asynchron und ressourcenschonend
- **Support & Erweiterbarkeit**: Sauberer Code, viele Hooks für eigene Erweiterungen
## Einrichtung & Konfiguration - MySQL oder Datei-Speicherung (YAML/JSON oder MySQL/MariaDB, jederzeit umschaltbar)
- Automatische Backups & Migration
- Export/Import von Tickets
- Statistiken & Archivierung
- Rollenbasierter Archiv-Zugriff
- Konfigurierbare Speicherpfade
- Vollständige Validierung & Fehlerausgaben
- Debug-Modus & Versionsprüfung
- Anpassbare Nachrichten, Farben, Limits, Speicherpfade, Archiv-Intervall, Cooldowns, Rechte
- Dynamische GUI mit Seiten-System
- Kategorie- und Prioritäten-System
- Bewertungs- und Kommentar-System
- Offline-Benachrichtigungen
- Discord-Webhook mit Embeds & Rollen-Ping
- Blacklist für Spieler
- **FAQ-System** mit eigenem GUI, eigener Datei (`faqs.yml`) und vollständiger In-Game-Verwaltung durch Admins
- **Performance-Caching** TTL-basierter In-Memory-Cache reduziert Datenbankabfragen spürbar
- **Saubere Konsole** minimale Start-Logs, kein unnötiger Spam
- Erweiterbarkeit: viele Hooks
- **BungeeCord-Unterstützung**: serverübergreifende Tickets, Teleports, Weiterleitungen, Benachrichtigungen
1. **config.yml** anpassen: ---
- `data-file`, `archive-file`: Speicherorte für Tickets und Archive (relativ oder absolut)
- `use-mysql`: true/false
- `auto-archive-interval-hours`: Intervall für automatische Archivierung (0 = aus)
- `debug`: true/false für ausführliche Logs
- `version`: Versionsprüfung für die config.yml
- Nachrichten, Farben, Limits, Cooldowns, Rechte individuell anpassbar
2. **MySQL** (optional): Zugangsdaten eintragen
3. **/ticket**-Befehl nutzen (inkl. Admin-Tools für Migration, Export, Import, Statistik, Archiv, Reload)
## Kompatibilität & Zielgruppe ## Installation & Setup
- Minecraft 1.18.x 1.21.x (Paper, Spigot, Purpur) 1. TicketSystem.jar in den plugins-Ordner legen und Server starten
- Java 17+ 2. `config.yml` anpassen (Speicherorte, Nachrichten, Limits, Farben, MySQL-Daten etc.)
- YAML/JSON oder MySQL/MariaDB 3. `/ticket`-Befehle nutzen
- Für Community-Server, Citybuild, Minigames, Survival, u.v.m.
## Support & Kontakt
---
## Befehle & Rechte ## Befehle & Rechte
- `/ticket` Hauptbefehl für alle Ticket-Funktionen ### Übersicht der Befehle
- `/ticket` Übersicht aller offenen Tickets (GUI)
- `/ticket create <Nachricht>` Erstellt ein neues Ticket
- `/ticket claim <ID>` Ticket übernehmen
- `/ticket close <ID>` Ticket schließen
- `/ticket forward <ID> <Spieler>` Ticket weiterleiten
- `/ticket archive` Tickets archivieren (manuell)
- `/ticket export <Datei>` Tickets exportieren
- `/ticket import <Datei>` Tickets importieren
- `/ticket migrate <tomysql|tofile>` Migration zwischen Speicherarten
- `/ticket stats` Statistiken anzeigen
- `/ticket reload` Konfiguration neu laden
- Rechte: | Befehl | Beschreibung | Nutzergruppe |
- `ticket.admin` Zugriff auf alle Admin- und Management-Funktionen |-----------------------------------------------------------|--------------------------------------------------------|------------------|
- `ticket.use` (Standard) Ticket erstellen und eigene Tickets verwalten | `/ticket` | Hilfe & Übersicht | Spieler, Support |
| `/ticket create [Kategorie] [Priorität] <Text>` | Ticket erstellen | Spieler |
| `/ticket list` | Eigene Tickets in der GUI anzeigen | Spieler |
| `/ticket comment <ID> <Nachricht>` | Kommentar hinzufügen | Spieler, Support |
| `/ticket rate <ID> <good\|bad>` | Support bewerten | Spieler |
| `/ticket faq` | FAQ-GUI öffnen (häufige Fragen) | Spieler |
| `/ticket faq list` | FAQs im Chat auflisten | Spieler |
| `/ticket faq add <Frage> \| <Antwort>` | Neues FAQ hinzufügen | Admin |
| `/ticket faq edit <ID> <Frage> \| <Antwort>` | Bestehendes FAQ bearbeiten | Admin |
| `/ticket faq delete <ID>` | FAQ löschen | Admin |
| `/ticket faq reload` | FAQs aus faqs.yml neu laden | Admin |
| `/ticket claim <ID>` | Ticket annehmen | Support/Admin |
| `/ticket close <ID> [Kommentar]` | Ticket schließen | Support/Admin |
| `/ticket forward <ID> <Spieler>` | Ticket weiterleiten | Support/Admin |
| `/ticket setpriority <ID> <low\|normal\|high\|urgent>` | Priorität ändern | Support/Admin |
| `/ticket reload` | Konfiguration neu laden | Admin |
| `/ticket stats` | Statistiken anzeigen | Admin |
| `/ticket archive` | Tickets archivieren | Admin |
| `/ticket blacklist <add\|remove\|list> [Spieler] [Grund]` | Blacklist verwalten | Admin |
| `/ticket migrate <tomysql\|tofile>` | Speicherart wechseln | Admin |
| `/ticket export <Dateiname>` | Tickets exportieren | Admin |
| `/ticket import <Dateiname>` | Tickets importieren | Admin |
| `/ticket teleport <ID>` | Teleport zu Ticket (BungeeCord) | Support/Admin |
## Beispiel-Konfiguration (Ausschnitt) ### Rechte
| Permission | Beschreibung | Standard |
|-----------------|-----------------------------------------------------------------|------------------|
| `ticket.create` | Ticket erstellen | alle Spieler |
| `ticket.support`| Tickets einsehen, claimen, schließen, Priorität ändern | manuell vergeben |
| `ticket.archive`| Archiv öffnen, Tickets permanent löschen | manuell vergeben |
| `ticket.admin` | Voller Zugriff inkl. Weiterleitung, Reload, Blacklist, FAQ-Verwaltung | OP |
> `ticket.archive` ist nicht in `ticket.admin` enthalten und muss explizit vergeben werden.
---
## Mehrsprachigkeit
TicketSystem unterstützt drei Sprachmodi, die über einen einzigen Schlüssel in der `config.yml` gesteuert werden:
```yaml ```yaml
version: "2.0" language: de # Deutsch deutsche Texte & deutsche Befehlsnamen
debug: false language: en # Englisch englische Texte & englische Befehlsnamen
data-file: "data.yml" language: both # Beides deutsche Texte & beide Befehlsnamen gleichzeitig
archive-file: "archive.yml"
use-mysql: false
use-json: false
auto-archive-interval-hours: 24
prefix: "&8[&6Ticket&8] &r"
ticket-cooldown: 60
max-description-length: 100
max-open-tickets-per-player: 2
``` ```
> `command-language` existiert nicht mehr. Alle Einstellungen laufen über `language`.
### Befehlsnamen je Sprachmodus
| Interner Schlüssel | `language: de` | `language: en` | `language: both` |
|--------------------|---------------------------|-------------------------|-------------------------------------------|
| `create` | `/ticket erstellen` | `/ticket create` | `/ticket create §8(§7erstellen§8)` |
| `list` | `/ticket liste` | `/ticket list` | `/ticket list §8(§7liste§8)` |
| `comment` | `/ticket kommentar` | `/ticket comment` | `/ticket comment §8(§7kommentar§8)` |
| `rate` | `/ticket bewerten` | `/ticket rate` | `/ticket rate §8(§7bewerten§8)` |
| `claim` | `/ticket übernehmen` | `/ticket claim` | `/ticket claim §8(§7übernehmen§8)` |
| `close` | `/ticket schließen` | `/ticket close` | `/ticket close §8(§7schließen§8)` |
| `forward` | `/ticket weiterleiten` | `/ticket forward` | `/ticket forward §8(§7weiterleiten§8)` |
| `reload` | `/ticket neuladen` | `/ticket reload` | `/ticket reload §8(§7neuladen§8)` |
| `stats` | `/ticket statistik` | `/ticket stats` | `/ticket stats §8(§7statistik§8)` |
| `archive` | `/ticket archivieren` | `/ticket archive` | `/ticket archive §8(§7archivieren§8)` |
| `migrate` | `/ticket migrieren` | `/ticket migrate` | `/ticket migrate §8(§7migrieren§8)` |
| `export` | `/ticket exportieren` | `/ticket export` | `/ticket export §8(§7exportieren§8)` |
| `import` | `/ticket importieren` | `/ticket import` | `/ticket import §8(§7importieren§8)` |
| `blacklist` | `/ticket sperrliste` | `/ticket blacklist` | `/ticket blacklist §8(§7sperrliste§8)` |
| `setpriority` | `/ticket priorität` | `/ticket setpriority` | `/ticket setpriority §8(§7priorität§8)` |
| `faq` | `/ticket faq` | `/ticket faq` | `/ticket faq` |
| `top` | `/ticket top` | `/ticket top` | `/ticket top` |
### `{cmd_X}`-Platzhalter in den Sprachdateien
Alle Hilfetexte, Fehlermeldungen und GUI-Hinweise in `lang_de.yml` / `lang_en.yml` verwenden `{cmd_X}`-Platzhalter statt fester Befehlsnamen. Beim Anzeigen ersetzt der `LanguageManager` diese automatisch anhand des aktiven Sprachmodus.
| Platzhalter | Beispiel-Ausgabe (`language: en`) |
|----------------------|-----------------------------------|
| `{cmd_create}` | `/ticket create` |
| `{cmd_list}` | `/ticket list` |
| `{cmd_comment}` | `/ticket comment` |
| `{cmd_rate}` | `/ticket rate` |
| `{cmd_claim}` | `/ticket claim` |
| `{cmd_close}` | `/ticket close` |
| `{cmd_forward}` | `/ticket forward` |
| `{cmd_reload}` | `/ticket reload` |
| `{cmd_stats}` | `/ticket stats` |
| `{cmd_archive}` | `/ticket archive` |
| `{cmd_migrate}` | `/ticket migrate` |
| `{cmd_export}` | `/ticket export` |
| `{cmd_import}` | `/ticket import` |
| `{cmd_blacklist}` | `/ticket blacklist` |
| `{cmd_setpriority}` | `/ticket setpriority` |
| `{cmd_faq}` | `/ticket faq` |
| `{cmd_top}` | `/ticket top` |
**Verwendungsbeispiel in der Sprachdatei:**
```yaml
help:
create: "&e{cmd_create} [Kategorie] <Text> &7 Neues Ticket erstellen"
# → language: de gibt aus: /ticket erstellen [Kategorie] <Text>
# → language: en gibt aus: /ticket create [Kategorie] <Text>
```
### GUI-Sprachanpassung
Seit dieser Version sind auch alle **GUI-Texte** vollständig sprachabhängig Inventar-Titel, Item-Lore-Labels und Action-Buttons werden aus `gui.item.*` in der aktiven Sprachdatei geladen. Nach einem `/ticket reload` mit geändertem `language`-Wert wechselt die gesamte Oberfläche sofort.
---
## FAQ-System
Das FAQ-System ermöglicht es Admins, häufige Fragen und Antworten direkt im Spiel zu verwalten. Spieler können die FAQs per GUI oder Befehl einsehen.
### Für Spieler
```
/ticket faq öffnet die FAQ-GUI mit Custom-Skull-Items
/ticket faq list listet alle FAQs im Chat auf
```
In der GUI wird für jeden FAQ-Eintrag ein **Custom-Skull-Item** mit individueller Textur angezeigt. Ein Klick auf einen Eintrag zeigt die vollständige Antwort im Chat.
### Für Admins
```
/ticket faq add Wie melde ich einen Bug? | Nutze /ticket create bug <Beschreibung>.
/ticket faq edit 1 Neue Frage? | Neue Antwort.
/ticket faq delete 1
/ticket faq reload
```
Alternativ können FAQs auch direkt über die **Admin-FAQ-GUI** verwaltet werden (öffnet sich automatisch wenn `ticket.admin`-Berechtigung vorhanden). Ein Klick auf einen Eintrag öffnet eine Aktions-GUI mit den Optionen **Bearbeiten** und **Löschen**. Neue FAQs lassen sich ebenfalls per Schaltfläche in der GUI hinzufügen das Plugin führt den Admin Schritt für Schritt durch eine Chat-Eingabe.
### faqs.yml
Alle FAQs werden in einer eigenen Datei `plugins/TicketSystem/faqs.yml` gespeichert:
```yaml
faqs:
1:
question: "Wie erstelle ich ein Ticket?"
answer: "Nutze den Befehl /ticket create [Kategorie] [Beschreibung]."
2:
question: "Wie lange dauert die Bearbeitung?"
answer: "Unser Support-Team bearbeitet Tickets so schnell wie möglich."
```
Beim ersten Start werden automatisch vier Beispiel-FAQs erstellt.
---
## Performance-Caching
Ab dieser Version hält TicketSystem häufig abgerufene Tickets in einem **TTL-basierten In-Memory-Cache** vor. Das reduziert die Datenbankbelastung bei wiederholten Zugriffen (GUI, Kommentare, Bewertungen) deutlich.
- Standard-Lebenszeit: **60 Sekunden** (konfigurierbar: `cache-ttl-seconds` in `config.yml`)
- Der Cache wird bei Ticket-Änderungen (Claim, Close, Priorität usw.) automatisch invalidiert
- Regelmäßige Bereinigung abgelaufener Einträge alle 5 Minuten
- Der aktuelle Cache-Status ist in `/ticket stats` einsehbar
---
## Kategorie & Priorität
Beim Erstellen eines Tickets können Kategorie und Priorität optional angegeben werden:
| Befehl | Kategorie | Priorität |
|-----------------------------------------|-----------|-----------|
| `/ticket create <Text>` | Standard | NORMAL |
| `/ticket create bug <Text>` | Bug | NORMAL |
| `/ticket create high <Text>` | Standard | HIGH |
| `/ticket create bug high <Text>` | Bug | HIGH |
| `/ticket create question urgent <Text>` | Frage | URGENT |
**Verfügbare Prioritäten:** `low`, `normal`, `high`, `urgent` (auch deutsch: `niedrig`, `hoch`, `dringend`)
Kategorien und Aliases sind frei in der `config.yml` konfigurierbar.
---
## Discord-Webhook
- Embeds mit Kategorie & Priorität
- Rollen-Ping pro Nachrichtentyp
- Drei Ereignisse: neues Ticket, Ticket geschlossen, Ticket weitergeleitet
Konfiguration in `config.yml`:
```yaml
discord:
enabled: true
webhook-url: "https://discord.com/api/webhooks/..."
role-ping-id: "123456789012345678"
messages:
new-ticket:
role-ping: true
show-category: true
show-priority: true
ticket-closed:
enabled: true
role-ping: false
```
---
## BungeeCord-Unterstützung
TicketSystem bietet volle Unterstützung für BungeeCord-Netzwerke:
- Tickets von jedem Server im Netzwerk
- Teleport zu Tickets auf anderen Servern (`/ticket teleport <ID>`)
- Tickets im Archiv und GUI serverübergreifend
- Benachrichtigungen an alle Server
- Discord-Webhooks zeigen Server-Namen
- Tickets an Supporter auf anderen Servern weiterleiten
**Voraussetzungen:**
- `spigot.yml`: `bungeecord: true`
- `config.yml`: `bungeecord: true`, `server-name` pro Server
- TicketSystem.jar auf allen Spigot-Servern
- Alle Server nutzen dieselbe MySQL-Datenbank
---
## Vergleich mit anderen Plugins
| Feature | TicketSystem | SimpleTickets | AdvancedTickets |
|-----------------------------|:------------:|:-------------:|:---------------:|
| Speicher-Migration | ✔️ | ⚠️ | ✖️ |
| Automatische Backups | ✔️ | ⚠️ | ✖️ |
| GUI mit Kategorien | ✔️ | ⚠️ | ✖️ |
| Archivierung | ✔️ | ⚠️ | ✖️ |
| Rollenbasierter Archiv-Zugriff| ✔️ | ✖️ | ✖️ |
| Kategorie-System | ✔️ | ✖️ | ✖️ |
| Prioritäten-System | ✔️ | ✖️ | ✖️ |
| FAQ-System | ✔️ | ✖️ | ✖️ |
| Performance-Caching | ✔️ | ✖️ | ✖️ |
| Offline-Benachrichtigungen | ✔️ | ✖️ | ✖️ |
| Discord-Webhook | ✔️ | ✖️ | ✖️ |
| Bewertungs-System | ✔️ | ✖️ | ✖️ |
| Update-Checker | ✔️ | ✖️ | ✖️ |
| BungeeCord-Unterstützung | ✔️ | ✖️ | ✖️ |
Legende: ✔️ Vollständige Unterstützung · ⚠️ Eingeschränkt · ✖️ Nicht vorhanden
---
## FAQ ## FAQ
**Kann ich zwischen MySQL und Datei-Speicherung wechseln?** **Kann ich zwischen MySQL und Datei-Speicherung wechseln?**
> Ja, jederzeit per Migrationstool oder Befehl `/ticket migrate ...`. > Ja! Mit `/ticket migrate tomysql` oder `/ticket migrate tofile` werden alle Daten automatisch migriert.
**Wie viele Tickets passen in die GUI?** **Wie konfiguriere ich eigene Kategorien?**
> Bis zu 54 pro Seite, bei mehr Tickets wird geblättert. > In der `config.yml` unter `categories:` Name, Farbe, Material und Aliases frei wählbar. Änderungen mit `/ticket reload` übernehmen.
**Wie kann ich Nachrichten und Limits anpassen?** **Wie verwalte ich FAQs?**
> Alle Texte, Farben und Limits findest du in der `config.yml`. > Mit `/ticket faq` öffnest du die GUI. Als Admin kannst du über die GUI oder per `/ticket faq add|edit|delete` FAQs verwalten. Alle Daten liegen in `faqs.yml`.
**Was passiert mit Benachrichtigungen wenn ein Spieler offline ist?**
> Alle Benachrichtigungen werden gespeichert und beim nächsten Login angezeigt.
**Wie ändere ich die Priorität eines Tickets?**
> Als Support/Admin per `/ticket setpriority <ID> <Priorität>` oder direkt in der GUI.
**Wie aktiviere ich den Debug-Modus?** **Wie aktiviere ich den Debug-Modus?**
> Setze `debug: true` in der `config.yml`. > `debug: true` in der `config.yml` setzen.
**Wie kann ich Tickets exportieren/importieren?** **Wer darf das Ticket-Archiv sehen?**
> Mit `/ticket export <Datei>` und `/ticket import <Datei>`. > Nur Spieler mit `ticket.archive`. Muss explizit vergeben werden.
## Support & Kontakt **Wie funktioniert Teleport bei BungeeCord?**
> Mit `/ticket teleport <ID>` wirst du automatisch auf den richtigen Server und zur Ticket-Position teleportiert.
Du hast Fragen, Feedback oder möchtest das Plugin erweitern? Melde dich direkt bei **Viper Plugins** wir helfen schnell und unkompliziert! **Wie lange werden Tickets gecacht?**
> Standardmäßig 60 Sekunden. Über `cache-ttl-seconds` in der `config.yml` anpassbar. Der Cache wird bei Änderungen sofort invalidiert.
---
## Support, Community & Motivation
Du hast Fragen, brauchst Hilfe oder möchtest Feedback geben?
- [Discord Support](https://discord.com/invite/FdRs4BRd8D)
- [Git Issues](https://git.viper.ipv64.net/M_Viper/TicketSystem/issues)
Wir antworten in der Regel innerhalb von 24 Stunden!
**Dein Feedback zählt:**
Wenn TicketSystem deinen Server bereichert hat, freuen wir uns über eine 5-Sterne Bewertung auf SpigotMC!
Jede Rückmeldung hilft, das Plugin weiter zu verbessern und die Community zu stärken.
---
**Copyright © 2026 - M_Viper - Alle Rechte vorbehalten**
Die unbefugte Vervielfältigung, Verbreitung oder Weitergabe dieses Plugins ist strafbar und wird rechtlich verfolgt.

View File

@@ -6,15 +6,16 @@
<groupId>de.ticketsystem</groupId> <groupId>de.ticketsystem</groupId>
<artifactId>TicketSystem</artifactId> <artifactId>TicketSystem</artifactId>
<version>1.0.1</version> <version>1.0.8</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>TicketSystem</name> <name>TicketSystem</name>
<description>Ingame Support Ticket System with MySQL</description> <description>Ingame Support Ticket System with MySQL</description>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>

View File

@@ -1,11 +1,18 @@
package de.ticketsystem; package de.ticketsystem;
import de.ticketsystem.bungee.BungeeMessenger;
import de.ticketsystem.cache.TicketCache;
import de.ticketsystem.commands.TicketCommand; import de.ticketsystem.commands.TicketCommand;
import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.discord.DiscordWebhook;
import de.ticketsystem.gui.FaqGUI;
import de.ticketsystem.gui.TicketGUI; import de.ticketsystem.gui.TicketGUI;
import de.ticketsystem.listeners.PlayerJoinListener; import de.ticketsystem.listeners.PlayerJoinListener;
import de.ticketsystem.manager.CategoryManager;
import de.ticketsystem.manager.FaqManager;
import de.ticketsystem.manager.LanguageManager;
import de.ticketsystem.manager.TicketManager; import de.ticketsystem.manager.TicketManager;
import de.ticketsystem.model.Ticket;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,120 +23,264 @@ public class TicketPlugin extends JavaPlugin {
private static TicketPlugin instance; private static TicketPlugin instance;
private boolean debug; private boolean debug;
private DatabaseManager databaseManager;
private TicketManager ticketManager; /**
private TicketGUI ticketGUI; * Name dieses Servers im BungeeCord-Netzwerk.
* Konfigurierbar in config.yml → server-name
*/
private String serverName;
private LanguageManager languageManager;
private DatabaseManager databaseManager;
private TicketManager ticketManager;
private CategoryManager categoryManager;
private FaqManager faqManager;
private TicketGUI ticketGUI;
private FaqGUI faqGUI;
private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger;
private TicketCache ticketCache;
@Override @Override
public void onEnable() { public void onEnable() {
instance = this; instance = this;
// Config speichern falls nicht vorhanden
saveDefaultConfig(); saveDefaultConfig();
// Update-Checker (Spigot Resource-ID anpassen!) // Ticket-Klasse für YAML-Serialisierung registrieren
int resourceId = 132757; Ticket.register();
// ── Sprachdatei laden (lang.yml) ──────────────────────────────────
// Muss VOR allen anderen Managern geschehen, da diese lang() nutzen.
languageManager = new LanguageManager(this);
// ── BungeeCord Plugin-Messaging-Kanäle registrieren ───────────────
getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.BUNGEE_CHANNEL);
getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL);
bungeeMessenger = new BungeeMessenger(this);
getServer().getMessenger().registerIncomingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL, bungeeMessenger);
// Server-Name aus Config lesen
serverName = getConfig().getString("server-name", "unknown");
if ("unknown".equals(serverName)) {
getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!");
}
// BungeeCord-Hinweis nur bei deaktiviertem Feature ausgeben
if (!getConfig().getBoolean("bungeecord", false)) {
getLogger().info("[BungeeCord] Cross-Server-Features deaktiviert. Setze 'bungeecord: true' um sie zu aktivieren.");
}
// Update-Checker
int resourceId = 132757;
new UpdateChecker(this, resourceId).getVersion(version -> { new UpdateChecker(this, resourceId).getVersion(version -> {
String current = getDescription().getVersion(); String current = getDescription().getVersion();
if (!current.equals(version)) { if (!current.equals(version)) {
String msg = ChatColor.translateAlternateColorCodes('&', String msg = lang().format("update.available-line1", "{version}", version);
"&6[TicketSystem] &eEs ist eine neue Version verfügbar: &a" + version + " &7(aktuell: " + current + ")"); getLogger().warning(lang().format("update.available-console",
getLogger().info("Es ist eine neue Version verfügbar: " + version + " (aktuell: " + current + ")"); "{new}", version, "{current}", current));
// Sende Nachricht an alle Admins (online) mit 1 Sekunde Verzögerung getServer().getScheduler().runTaskLater(this, () ->
getServer().getScheduler().runTaskLater(this, () -> {
getServer().getOnlinePlayers().stream() getServer().getOnlinePlayers().stream()
.filter(p -> p.hasPermission("ticket.admin")) .filter(p -> p.hasPermission("ticket.admin"))
.forEach(p -> p.sendMessage(msg)); .forEach(p -> p.sendMessage(msg)), 20L);
}, 20L); // 20 Ticks = 1 Sekunde
} else {
getLogger().info("TicketSystem ist aktuell (Version " + current + ")");
} }
}); });
// Versionsprüfung // Versionsprüfung der config.yml
String configVersion = getConfig().getString("version", ""); String configVersion = getConfig().getString("version", "");
String expectedVersion = "2.0"; String expectedVersion = "2.2";
if (!expectedVersion.equals(configVersion)) { if (!expectedVersion.equals(configVersion)) {
getLogger().warning("[WARNUNG] Die Version deiner config.yml (" + configVersion + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein! Bitte prüfe, ob deine Konfiguration aktuell ist."); getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion
+ ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!");
} }
// Debug-Status aus Config lesen
debug = getConfig().getBoolean("debug", false); debug = getConfig().getBoolean("debug", false);
// Datenbankverbindung aufbauen // ── Performance: Ticket-Cache ──────────────────────────────────────
long cacheTtl = getConfig().getLong("cache-ttl-seconds", 60) * 1000L;
ticketCache = new TicketCache(cacheTtl);
// Regelmäßige Cache-Bereinigung alle 5 Minuten
getServer().getScheduler().runTaskTimerAsynchronously(this,
() -> ticketCache.evictExpired(), 6000L, 6000L);
// Datenbankverbindung
databaseManager = new DatabaseManager(this); databaseManager = new DatabaseManager(this);
if (!databaseManager.connect()) { if (!databaseManager.connect()) {
getLogger().severe("Konnte keine Datenbankverbindung herstellen! Plugin läuft im Datei-Modus weiter."); getLogger().severe("Konnte keine Datenbankverbindung herstellen! Plugin läuft im Datei-Modus weiter.");
if (isDebug()) getLogger().warning("[DEBUG] DatabaseManager.connect() fehlgeschlagen, Datei-Modus aktiviert.");
// Plugin bleibt aktiv, DatabaseManager wechselt auf Datei-Storage
} }
// Manager und GUI initialisieren // Manager, GUI, FAQ & Discord-Webhook initialisieren
ticketManager = new TicketManager(this); categoryManager = new CategoryManager(this);
ticketGUI = new TicketGUI(this); ticketManager = new TicketManager(this);
faqManager = new FaqManager(this);
ticketGUI = new TicketGUI(this);
faqGUI = new FaqGUI(this);
discordWebhook = new DiscordWebhook(this);
// Commands registrieren if (getConfig().getBoolean("discord.enabled", false)) {
String url = getConfig().getString("discord.webhook-url", "");
if (url.isEmpty()) {
getLogger().warning("[DiscordWebhook] Aktiviert, aber keine Webhook-URL in config.yml eingetragen!");
}
}
// Commands & Events
TicketCommand ticketCommand = new TicketCommand(this); TicketCommand ticketCommand = new TicketCommand(this);
Objects.requireNonNull(getCommand("ticket")).setExecutor(ticketCommand); Objects.requireNonNull(getCommand("ticket")).setExecutor(ticketCommand);
Objects.requireNonNull(getCommand("ticket")).setTabCompleter(ticketCommand); Objects.requireNonNull(getCommand("ticket")).setTabCompleter(ticketCommand);
// Events registrieren
getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this); getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this);
getServer().getPluginManager().registerEvents(ticketGUI, this); getServer().getPluginManager().registerEvents(ticketGUI, this);
getServer().getPluginManager().registerEvents(faqGUI, this);
// Automatische Archivierung nach Zeitplan (Intervall in Stunden, Standard: 24h) // Automatische Archivierung
int archiveIntervalH = getConfig().getInt("auto-archive-interval-hours", 24); int archiveIntervalH = getConfig().getInt("auto-archive-interval-hours", 24);
if (archiveIntervalH > 0) { if (archiveIntervalH > 0) {
long ticks = archiveIntervalH * 60L * 60L * 20L; // Stunden → Ticks long ticks = archiveIntervalH * 60L * 60L * 20L;
getServer().getScheduler().runTaskTimer(this, () -> { getServer().getScheduler().runTaskTimer(this, () -> {
int archived = databaseManager.archiveClosedTickets(); int archived = databaseManager.archiveClosedTickets();
if (archived > 0) { if (archived > 0) {
getLogger().info("Automatische Archivierung: " + archived + " Tickets archiviert."); getLogger().info("Automatische Archivierung: " + archived + " Tickets archiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierung ausgeführt, " + archived + " Tickets verschoben.");
} }
}, ticks, ticks); }, ticks, ticks);
getLogger().info("Automatische Archivierung alle " + archiveIntervalH + " Stunden aktiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierungs-Timer gesetzt: alle " + archiveIntervalH + " Stunden.");
} }
getLogger().info("TicketSystem erfolgreich gestartet!"); getLogger().info("TicketSystem v" + getDescription().getVersion() + " erfolgreich gestartet!");
} }
@Override @Override
public void onDisable() { public void onDisable() {
if (databaseManager != null) { getServer().getMessenger().unregisterOutgoingPluginChannel(this);
databaseManager.disconnect(); getServer().getMessenger().unregisterIncomingPluginChannel(this);
}
if (ticketCache != null) ticketCache.clear();
if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert."); getLogger().info("TicketSystem wurde deaktiviert.");
} }
// ─────────────────────────── Hilfsmethoden ───────────────────────────── // ─────────────────────────── Hilfsmethoden ─────────────────────────────
/** /**
* Formatiert eine Nachricht aus der Config mit Prefix und Farben. * Gibt den LanguageManager zurück bevorzugte Methode für alle Texte.
*
* Verwendung:
* plugin.lang().get("ticket.created")
* plugin.lang().format("ticket.created", "{id}", String.valueOf(id))
* plugin.lang().send(player, "ticket.created", "{id}", String.valueOf(id))
*/ */
public String formatMessage(String path) { public LanguageManager lang() {
String prefix = color(getConfig().getString("prefix", "&8[&6Ticket&8] &r")); return languageManager;
String message = getConfig().getString(path, "&cNachricht nicht gefunden: " + path);
return prefix + color(message);
} }
/** /**
* Konvertiert Farbcodes (&x → §x). * Kompatibilitätsmethode für bestehenden Code.
* Liest Pfade der Form "messages.xxx" aus lang.yml (ohne "messages."-Prefix).
*
* Beispiel: formatMessage("messages.ticket-created") → lang "ticket.created"
*
* @deprecated Direkt {@link #lang()} verwenden.
*/
@Deprecated
public String formatMessage(String path) {
// "messages.ticket-created" → "ticket.created" (legacy-Mapping)
String langKey = mapLegacyPath(path);
if (langKey != null) {
return lang().formatWithPrefix(langKey);
}
// Fallback: Direkt in lang.yml nachschlagen
String value = lang().getRaw(path);
return lang().getPrefix() + lang().color(value);
}
/**
* Übersetzt &-Farbcodes in §-Farbcodes.
* Kompatibilitätsmethode bevorzugt lang().color() verwenden.
*/ */
public String color(String text) { public String color(String text) {
return ChatColor.translateAlternateColorCodes('&', text); return ChatColor.translateAlternateColorCodes('&', text);
} }
/**
* Mappt alte "messages.xxx"-Pfade auf neue lang.yml-Pfade.
* Muss ergänzt werden wenn neue Schlüssel im alten Stil genutzt wurden.
*/
private String mapLegacyPath(String path) {
if (path == null) return null;
return switch (path) {
case "messages.export-success" -> "system.export-success";
case "messages.export-fail" -> "system.export-fail";
case "messages.import-success" -> "system.import-success";
case "messages.import-fail" -> "system.import-fail";
case "messages.migration-success" -> "system.migration-success";
case "messages.migration-fail" -> "system.migration-fail";
case "messages.archive-success" -> "system.archive-success";
case "messages.archive-fail" -> "system.archive-fail";
case "messages.file-not-found" -> "system.file-not-found";
case "messages.unknown-mode" -> "system.unknown-mode";
case "messages.validation-warning" -> "system.validation-warning";
case "messages.ticket-created" -> "ticket.created";
case "messages.ticket-created-category" -> "ticket.created-category";
case "messages.ticket-claimed" -> "ticket.claimed";
case "messages.ticket-claimed-notify" -> "ticket.claimed-notify";
case "messages.ticket-closed" -> "ticket.closed";
case "messages.ticket-closed-notify" -> "ticket.closed-notify";
case "messages.ticket-forwarded" -> "ticket.forwarded";
case "messages.ticket-forwarded-notify" -> "ticket.forwarded-notify";
case "messages.ticket-forwarded-creator-notify" -> "ticket.forwarded-creator";
case "messages.new-ticket-notify" -> "ticket.new-notify";
case "messages.comment-saved" -> "comment.saved";
case "messages.comment-notify" -> "comment.notify-online";
case "messages.comment-no-permission" -> "comment.no-permission";
case "messages.rating-saved-good" -> "rating.saved-good";
case "messages.rating-saved-bad" -> "rating.saved-bad";
case "messages.rating-already-rated" -> "rating.already-rated";
case "messages.rating-not-yours" -> "rating.not-yours";
case "messages.rating-disabled" -> "rating.disabled";
case "messages.rating-prompt" -> "rating.prompt-title";
case "messages.blacklist-added" -> "blacklist.added";
case "messages.blacklist-removed" -> "blacklist.removed";
case "messages.blacklist-already" -> "blacklist.already";
case "messages.blacklist-not-found" -> "blacklist.not-found";
case "messages.blacklist-blocked" -> "create.blacklist-blocked";
case "messages.no-permission" -> "general.no-permission";
case "messages.no-open-tickets" -> "general.no-open-tickets";
case "messages.join-open-tickets" -> "join.open-tickets";
case "messages.already-claimed" -> "general.already-claimed";
case "messages.ticket-not-found" -> "general.ticket-not-found";
case "messages.cooldown" -> "general.cooldown";
case "messages.category-invalid" -> "create.category-invalid";
default -> null;
};
}
// ─────────────────────────── Getter ──────────────────────────────────── // ─────────────────────────── Getter ────────────────────────────────────
public static TicketPlugin getInstance() { return instance; }
public DatabaseManager getDatabaseManager() { return databaseManager; }
public TicketManager getTicketManager() { return ticketManager; }
public TicketGUI getTicketGUI() { return ticketGUI; }
/** /**
* Gibt zurück, ob der Debug-Modus aktiv ist (aus config.yml) * Aktualisiert serverName und debug-Flag aus der (bereits neu geladenen) Config.
* Muss nach plugin.reloadConfig() aufgerufen werden.
*/ */
public boolean isDebug() { return debug; } public void reloadSettings() {
} serverName = getConfig().getString("server-name", "unknown");
if ("unknown".equals(serverName)) {
getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert!");
}
debug = getConfig().getBoolean("debug", false);
}
public static TicketPlugin getInstance() { return instance; }
public LanguageManager getLanguageManager() { return languageManager; }
public DatabaseManager getDatabaseManager() { return databaseManager; }
public TicketManager getTicketManager() { return ticketManager; }
public CategoryManager getCategoryManager() { return categoryManager; }
public FaqManager getFaqManager() { return faqManager; }
public TicketGUI getTicketGUI() { return ticketGUI; }
public FaqGUI getFaqGUI() { return faqGUI; }
public DiscordWebhook getDiscordWebhook() { return discordWebhook; }
public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; }
public TicketCache getTicketCache() { return ticketCache; }
public boolean isDebug() { return debug; }
public String getServerName() { return serverName; }
public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); }
}

View File

@@ -10,27 +10,65 @@ import java.util.function.Consumer;
/** /**
* UpdateChecker für SpigotMC-Plugins. * UpdateChecker für SpigotMC-Plugins.
* Prüft asynchron, ob eine neue Version verfügbar ist. * Prüft asynchron ob eine neue Version verfügbar ist.
* Quelle: https://www.spigotmc.org/wiki/creating-an-update-checker-that-checks-for-updates * Gibt den Consumer nur aus, wenn die Spigot-Version NEUER ist als die lokale.
*/ */
public class UpdateChecker { public class UpdateChecker {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final int resourceId; private final int resourceId;
public UpdateChecker(JavaPlugin plugin, int resourceId) { public UpdateChecker(JavaPlugin plugin, int resourceId) {
this.plugin = plugin; this.plugin = plugin;
this.resourceId = resourceId; this.resourceId = resourceId;
} }
public void getVersion(final Consumer<String> consumer) { public void getVersion(final Consumer<String> consumer) {
Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> {
try (InputStream is = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + this.resourceId).openStream(); Scanner scann = new Scanner(is)) { try (InputStream is = new URL("https://api.spigotmc.org/legacy/update.php?resource="
+ this.resourceId).openStream();
Scanner scann = new Scanner(is)) {
if (scann.hasNext()) { if (scann.hasNext()) {
consumer.accept(scann.next()); String spigotVersion = scann.next().trim();
String localVersion = plugin.getDescription().getVersion().trim();
// Nur melden wenn Spigot-Version wirklich neuer ist
if (isNewerVersion(spigotVersion, localVersion)) {
consumer.accept(spigotVersion);
}
} }
} catch (IOException e) { } catch (IOException e) {
plugin.getLogger().info("Unable to check for updates: " + e.getMessage()); // Netzwerkfehler schweigen kein Spam in der Konsole
if (((TicketPlugin) plugin).isDebug()) {
plugin.getLogger().info("[UpdateChecker] Konnte nicht prüfen: " + e.getMessage());
}
} }
}); });
} }
}
/**
* Vergleicht zwei semantische Versionen (z.B. "1.0.5" und "1.0.6").
*
* @param spigot Version von SpigotMC
* @param local Lokale Plugin-Version
* @return true wenn spigot NEUER ist als local
*/
private boolean isNewerVersion(String spigot, String local) {
try {
String[] spigotParts = spigot.split("\\.");
String[] localParts = local.split("\\.");
int length = Math.max(spigotParts.length, localParts.length);
for (int i = 0; i < length; i++) {
int s = i < spigotParts.length ? Integer.parseInt(spigotParts[i]) : 0;
int l = i < localParts.length ? Integer.parseInt(localParts[i]) : 0;
if (s > l) return true; // Spigot ist neuer
if (s < l) return false; // Lokal ist neuer (z.B. noch nicht veröffentlicht)
}
return false; // Versionen identisch
} catch (NumberFormatException e) {
// Fallback: einfacher String-Vergleich falls Format ungewöhnlich
return !spigot.equals(local);
}
}
}

View File

@@ -0,0 +1,263 @@
package de.ticketsystem.bungee;
import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import de.ticketsystem.TicketPlugin;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.UUID;
/**
* Verwaltet die BungeeCord Plugin-Messaging-Kanäle für Cross-Server-Kommunikation.
*
* Kanalübersicht:
* Ausgehend: "BungeeCord" Standard-BungeeCord-Kanal (Forward, Message)
* Eingehend: "ticketsystem:notify" Eigener Kanal für weitergeleitete Nachrichten
*
* Voraussetzung:
* - In spigot.yml muss "bungeecord: true" gesetzt sein
* - In plugin.yml müssen beide Kanäle unter "channels:" deklariert sein
*
* Pakettypen (erstes Byte bei ticketsystem:notify):
* 0x01 = TEAM_NOTIFY Nachricht an alle Online-Supporter/Admins auf diesem Server
* 0x02 = PLAYER_MSG Nachricht an einen bestimmten Spieler (UUID + Text)
*
* ── BUG FIX ──────────────────────────────────────────────────────────────────
* Problem: BungeeCord's "Forward ALL" liefert auf dem Zielserver den inneren
* Payload BEREITS ENTPACKT via onPluginMessageReceived auf dem
* CUSTOM_CHANNEL. Das war korrekt implementiert.
*
* Der eigentliche Fehler lag in broadcastTeamNotification():
* - Nachrichten mit "\n" wurden als ein einzelner String gesendet.
* Minecraft verarbeitet "\n" in sendMessage() nicht → beide Zeilen kamen
* als eine zusammen an (unleserlich, aber nicht die Ursache für "gar nichts").
* - Die Methode wird jetzt mit einer Liste von Strings aufgerufen (broadcastLines)
* damit jede Zeile als separates Paket gesendet wird klar und lesbar.
*
* Hauptursache für "gar nichts auf Lobby":
* Die plugin.yml hatte keinen "channels:"-Block. Ohne diesen Eintrag
* registriert BungeeCord den Kanal "ticketsystem:notify" nicht und
* verwirft alle eingehenden Forward-Pakete lautlos auf den Ziel-Servern.
* → plugin.yml Fix ist die primäre Lösung.
*
* Diese Datei enthält zusätzlich Debug-Logging (wenn debug: true in config.yml)
* damit zukünftige Probleme schneller gefunden werden können.
* ─────────────────────────────────────────────────────────────────────────────
*/
public class BungeeMessenger implements PluginMessageListener {
/** BungeeCord-Standardkanal für Forward/Message-Subkanäle */
public static final String BUNGEE_CHANNEL = "BungeeCord";
/** Eigener Weiterleitungskanal muss in plugin.yml unter channels stehen */
public static final String CUSTOM_CHANNEL = "ticketsystem:notify";
private static final byte TYPE_TEAM_NOTIFY = 0x01;
private static final byte TYPE_PLAYER_MSG = 0x02;
private final TicketPlugin plugin;
public BungeeMessenger(TicketPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────── Ausgehende Nachrichten ────────────────────
/**
* Sendet eine Chat-Nachricht an einen bestimmten Spieler egal auf welchem
* Server im Netzwerk er sich befindet.
*
* Reihenfolge:
* 1. Spieler ist lokal online → direkte Zustellung
* 2. Spieler ist woanders → BungeeCord "Message"-Subkanal (nach Name)
* 3. Spieler ist offline → Pendende DB-Benachrichtigung (vorher speichern!)
*/
public void sendMessageToPlayer(UUID targetUUID, String targetName, String message) {
// 1. Lokal online?
Player local = Bukkit.getPlayer(targetUUID);
if (local != null && local.isOnline()) {
local.sendMessage(message);
return;
}
// 2. Cross-Server via BungeeCord "Message"-Subkanal
Player messenger = getAnyOnlinePlayer();
if (messenger == null || targetName == null) return;
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("Message");
out.writeUTF(targetName);
out.writeUTF(message);
messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray());
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][BungeeMessenger] sendMessageToPlayer → " + targetName + ": " + message);
}
}
/**
* Broadcastet eine Team-Benachrichtigung an alle Supporter/Admins im gesamten Netzwerk.
*
* Lokal online Spieler werden sofort benachrichtigt.
* Alle anderen Server erhalten das Paket über den "Forward ALL"-Mechanismus.
*
* WICHTIG: Jede Zeile wird als separates Paket gesendet damit Minecraft
* die Nachrichten korrekt zeilenweise anzeigt.
*/
public void broadcastTeamNotification(String message) {
// Lokale Supporter direkt benachrichtigen
Bukkit.getOnlinePlayers().stream()
.filter(p -> p.hasPermission("ticket.support") || p.hasPermission("ticket.admin"))
.forEach(p -> p.sendMessage(message));
// An alle anderen Server forwarden
Player messenger = getAnyOnlinePlayer();
if (messenger == null) {
if (plugin.isDebug()) {
plugin.getLogger().warning("[DEBUG][BungeeMessenger] broadcastTeamNotification: kein Bote online Forward nicht möglich!");
}
return;
}
sendForwardPacket(messenger, message);
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][BungeeMessenger] broadcastTeamNotification gesendet via " + messenger.getName() + ": " + message);
}
}
/**
* Sendet eine Nachricht an einen bestimmten Spieler via eigenem Forward-Paket.
*/
public void forwardPlayerMessage(UUID targetUUID, String targetName, String message) {
Player local = Bukkit.getPlayer(targetUUID);
if (local != null && local.isOnline()) {
local.sendMessage(message);
return;
}
Player messenger = getAnyOnlinePlayer();
if (messenger == null) return;
byte[] uuidBytes = targetUUID.toString().getBytes(StandardCharsets.UTF_8);
byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
ByteArrayDataOutput inner = ByteStreams.newDataOutput();
inner.writeByte(TYPE_PLAYER_MSG);
inner.writeInt(uuidBytes.length);
inner.write(uuidBytes);
inner.writeInt(msgBytes.length);
inner.write(msgBytes);
byte[] innerBytes = inner.toByteArray();
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("Forward");
out.writeUTF("ALL");
out.writeUTF(CUSTOM_CHANNEL);
out.writeInt(innerBytes.length);
out.write(innerBytes);
messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray());
}
// ─────────────────────────── Eingehende Nachrichten ────────────────────
@Override
public void onPluginMessageReceived(String channel, Player player, byte[] data) {
if (!CUSTOM_CHANNEL.equals(channel)) return;
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][BungeeMessenger] Paket empfangen auf " + channel + ", " + data.length + " Bytes");
}
try {
ByteArrayDataInput in = ByteStreams.newDataInput(data);
byte type = in.readByte();
if (type == TYPE_TEAM_NOTIFY) {
// Rest der Bytes = UTF-8-kodierte Nachricht
int len = data.length - 1;
byte[] msgBytes = new byte[len];
in.readFully(msgBytes);
String message = new String(msgBytes, StandardCharsets.UTF_8);
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][BungeeMessenger] TEAM_NOTIFY empfangen: " + message);
}
// Im Hauptthread an lokale Supporter zustellen
Bukkit.getScheduler().runTask(plugin, () ->
Bukkit.getOnlinePlayers().stream()
.filter(p -> p.hasPermission("ticket.support") || p.hasPermission("ticket.admin"))
.forEach(p -> p.sendMessage(message))
);
} else if (type == TYPE_PLAYER_MSG) {
int uuidLen = in.readInt();
byte[] uuidBytes = new byte[uuidLen];
in.readFully(uuidBytes);
UUID targetUUID = UUID.fromString(new String(uuidBytes, StandardCharsets.UTF_8));
int msgLen = in.readInt();
byte[] msgBytes = new byte[msgLen];
in.readFully(msgBytes);
String message = new String(msgBytes, StandardCharsets.UTF_8);
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][BungeeMessenger] PLAYER_MSG empfangen für: " + targetUUID);
}
Bukkit.getScheduler().runTask(plugin, () -> {
Player target = Bukkit.getPlayer(targetUUID);
if (target != null && target.isOnline()) {
target.sendMessage(message);
}
});
} else {
plugin.getLogger().warning("[BungeeMessenger] Unbekannter Pakettyp: " + type);
}
} catch (Exception e) {
plugin.getLogger().warning("[BungeeMessenger] Fehler beim Verarbeiten einer Plugin-Message: " + e.getMessage());
if (plugin.isDebug()) e.printStackTrace();
}
}
// ─────────────────────────── Hilfsmethoden ─────────────────────────────
/**
* Baut und sendet ein Forward-ALL-Paket mit TYPE_TEAM_NOTIFY.
*/
private void sendForwardPacket(Player messenger, String message) {
byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
ByteArrayDataOutput inner = ByteStreams.newDataOutput();
inner.writeByte(TYPE_TEAM_NOTIFY);
inner.write(msgBytes);
byte[] innerBytes = inner.toByteArray();
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("Forward");
out.writeUTF("ALL");
out.writeUTF(CUSTOM_CHANNEL);
out.writeInt(innerBytes.length);
out.write(innerBytes);
messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray());
}
/**
* Gibt einen beliebigen online Spieler zurück der als "Bote" für Plugin-Messages
* verwendet werden kann. BungeeCord verlangt einen Spieler als Absender.
*/
private Player getAnyOnlinePlayer() {
Collection<? extends Player> online = Bukkit.getOnlinePlayers();
return online.isEmpty() ? null : online.iterator().next();
}
}

View File

@@ -0,0 +1,93 @@
package de.ticketsystem.cache;
import de.ticketsystem.model.Ticket;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Einfacher TTL-basierter In-Memory-Cache für Ticket-Objekte.
*
* Reduziert wiederholte Datenbankabfragen beim häufigen Lesen desselben
* Tickets (z.B. beim Öffnen der GUI, Kommentarbenachrichtigungen usw.).
*
* Standard-TTL: 60 Sekunden (konfigurierbar per Konstruktor).
*
* Thread-sicher (ConcurrentHashMap) kann aus asynchronen Tasks heraus
* lese- und schreibend aufgerufen werden.
*/
public class TicketCache {
private static final long DEFAULT_TTL_MS = 60_000L; // 60 Sekunden
private final long ttlMs;
/** Cache-Eintrag: Ticket + Verfallszeitstempel */
private record CacheEntry(Ticket ticket, long expiresAt) {}
private final Map<Integer, CacheEntry> cache = new ConcurrentHashMap<>();
// ─────────────────────────── Konstruktor ───────────────────────────────
public TicketCache() {
this(DEFAULT_TTL_MS);
}
public TicketCache(long ttlMs) {
this.ttlMs = ttlMs;
}
// ─────────────────────────── Public API ────────────────────────────────
/**
* Gibt ein gecachtes Ticket zurück oder {@code null} wenn nicht vorhanden
* oder der Eintrag abgelaufen ist.
*/
public Ticket get(int ticketId) {
CacheEntry entry = cache.get(ticketId);
if (entry == null) return null;
if (System.currentTimeMillis() > entry.expiresAt()) {
cache.remove(ticketId);
return null;
}
return entry.ticket();
}
/**
* Speichert ein Ticket im Cache.
* Überschreibt vorhandene Einträge.
*/
public void put(Ticket ticket) {
if (ticket == null) return;
cache.put(ticket.getId(), new CacheEntry(ticket, System.currentTimeMillis() + ttlMs));
}
/**
* Entfernt ein Ticket aus dem Cache (z.B. nach einem Update).
*/
public void invalidate(int ticketId) {
cache.remove(ticketId);
}
/**
* Leert den gesamten Cache (z.B. nach einem Plugin-Reload).
*/
public void clear() {
cache.clear();
}
/**
* Entfernt alle abgelaufenen Einträge.
* Sollte periodisch aufgerufen werden um Speicher freizugeben.
*/
public void evictExpired() {
long now = System.currentTimeMillis();
cache.entrySet().removeIf(e -> now > e.getValue().expiresAt());
}
/** Gibt die aktuelle Anzahl der (möglicherweise teils abgelaufenen) Einträge zurück. */
public int size() {
return cache.size();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
package de.ticketsystem.discord;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketPriority;
import org.bukkit.Bukkit;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class DiscordWebhook {
// ─────────────────────────────────────────────────────────────────────────
// Konstanten & Felder
// ─────────────────────────────────────────────────────────────────────────
private static final String AVATAR_URL = "https://mc-heads.net/avatar/%s/64";
private final TicketPlugin plugin;
// ─────────────────────────────────────────────────────────────────────────
// Konstruktor
// ─────────────────────────────────────────────────────────────────────────
public DiscordWebhook(TicketPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────────────────────
// Öffentliche Methoden Webhook-Events
// ─────────────────────────────────────────────────────────────────────────
public void sendNewTicket(Ticket ticket) {
if (!isEnabled()) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "Neues Ticket");
String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "5793266");
String footer = plugin.getConfig().getString ("discord.messages.new-ticket.footer", "TicketSystem");
boolean showPos = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-position", true);
boolean showCat = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.new-ticket.role-ping", true);
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Felder aufbauen
List<Field> fields = new ArrayList<>();
fields.add(new Field("👤 Spieler", ticket.getCreatorName(), true));
fields.add(new Field("🎫 Ticket", "#" + ticket.getId(), true));
if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
if (showPos) {
fields.add(new Field("🌍 Welt", ticket.getWorldName(), true));
fields.add(new Field("📍 Position",
String.format("%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()), true));
}
// JSON zusammenbauen & senden
String description = "**Anliegen**\n> " + j(ticket.getMessage());
String json = buildJson(
ping ? buildRolePing() : "",
prioEmoji + " " + j(title) + " #" + ticket.getId(),
description,
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Neues Ticket"
);
sendAsync(webhookUrl, json);
}
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketClosed(Ticket ticket, String closerName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "Ticket geschlossen");
String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15548997");
String footer = plugin.getConfig().getString ("discord.messages.ticket-closed.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-closed.role-ping", false);
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Beschreibung aufbauen
StringBuilder desc = new StringBuilder();
desc.append("**Anliegen**\n> ").append(j(ticket.getMessage()));
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) {
desc.append("\n\n**Kommentar des Supports**\n> ").append(j(ticket.getCloseComment()));
}
// Felder aufbauen
List<Field> fields = new ArrayList<>();
fields.add(new Field("👤 Ersteller", ticket.getCreatorName(), true));
fields.add(new Field("🔒 Geschlossen von", j(closerName), true));
fields.add(new Field("🎫 Ticket ID", "#" + ticket.getId(), true));
if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
if (plugin.getConfig().getBoolean("rating-enabled", true) && ticket.getPlayerRating() != null) {
String rating = "THUMBS_UP".equals(ticket.getPlayerRating()) ? "👍 Positiv" : "👎 Negativ";
fields.add(new Field("⭐ Bewertung", rating, true));
}
// JSON zusammenbauen & senden
String json = buildJson(
ping ? buildRolePing() : "",
"🔒 " + j(title) + " #" + ticket.getId(),
desc.toString(),
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Ticket geschlossen"
);
sendAsync(webhookUrl, json);
}
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketForwarded(Ticket ticket, String fromName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.ticket-forwarded.title", "Ticket weitergeleitet");
String color = plugin.getConfig().getString ("discord.messages.ticket-forwarded.color", "15105570");
String footer = plugin.getConfig().getString ("discord.messages.ticket-forwarded.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.role-ping", false);
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Felder aufbauen
String forwardedTo = ticket.getForwardedToName() != null ? j(ticket.getForwardedToName()) : "";
List<Field> fields = new ArrayList<>();
fields.add(new Field("👤 Ersteller", ticket.getCreatorName(), true));
fields.add(new Field("📤 Weitergeleitet von", j(fromName), true));
fields.add(new Field("📥 Weitergeleitet an", forwardedTo, true));
fields.add(new Field("🎫 Ticket ID", "#" + ticket.getId(), true));
if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
// JSON zusammenbauen & senden
String json = buildJson(
ping ? buildRolePing() : "",
"🔀 " + j(title) + " #" + ticket.getId(),
"**Anliegen**\n> " + j(ticket.getMessage()),
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Ticket weitergeleitet"
);
sendAsync(webhookUrl, json);
}
// ─────────────────────────────────────────────────────────────────────────
// JSON-Bau
// ─────────────────────────────────────────────────────────────────────────
private record Field(String name, String value, boolean inline) {}
/**
* Baut den kompletten JSON-Payload ohne String.format()-Chaos.
* Kein verschachteltes Escaping, kein ungültiges JSON.
*/
private String buildJson(
String content,
String title,
String description,
int color,
String authorName,
String authorIcon,
String thumbnailUrl,
List<Field> fields,
String footer
) {
// Fields-Array aufbauen
StringBuilder fieldsJson = new StringBuilder("[");
for (int i = 0; i < fields.size(); i++) {
Field f = fields.get(i);
if (i > 0) fieldsJson.append(",");
fieldsJson
.append("{")
.append("\"name\":") .append(jsonString(f.name())) .append(",")
.append("\"value\":") .append(jsonString(f.value())) .append(",")
.append("\"inline\":") .append(f.inline())
.append("}");
}
fieldsJson.append("]");
// Embed-Objekt aufbauen
StringBuilder embed = new StringBuilder("{");
embed.append("\"title\":") .append(jsonString(title)) .append(",");
embed.append("\"description\":") .append(jsonString(description)) .append(",");
embed.append("\"color\":") .append(color) .append(",");
embed.append("\"author\":{")
.append("\"name\":") .append(jsonString(authorName)) .append(",")
.append("\"icon_url\":") .append(jsonString(authorIcon))
.append("},");
embed.append("\"thumbnail\":{\"url\":").append(jsonString(thumbnailUrl)).append("},");
embed.append("\"fields\":") .append(fieldsJson) .append(",");
embed.append("\"footer\":{\"text\":").append(jsonString(footer)) .append("},");
embed.append("\"timestamp\":") .append(jsonString(Instant.now().toString()));
embed.append("}");
// Root-Objekt
return "{" +
"\"content\":" + jsonString(content) + "," +
"\"embeds\":[" + embed + "]" +
"}";
}
/**
* Gibt einen JSON-String-Wert zurück inkl. Anführungszeichen.
* Alle Sonderzeichen werden korrekt escaped.
*/
private String jsonString(String value) {
if (value == null) value = "";
StringBuilder sb = new StringBuilder("\"");
for (char c : value.toCharArray()) {
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> { /* ignorieren */ }
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
sb.append("\"");
return sb.toString();
}
/**
* Kurz-Alias: Escaped einen Wert für die Verwendung innerhalb von
* description-Strings (die bereits durch jsonString() laufen).
*/
private String j(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", " ")
.replace("\r", "");
}
// ─────────────────────────────────────────────────────────────────────────
// Hilfsmethoden
// ─────────────────────────────────────────────────────────────────────────
private boolean isEnabled() {
return plugin.getConfig().getBoolean("discord.enabled", false);
}
private String getWebhookUrl() {
String url = plugin.getConfig().getString("discord.webhook-url", "");
return url.isEmpty() ? null : url;
}
private String buildRolePing() {
String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim();
return roleId.isEmpty() ? "" : "<@&" + roleId + ">";
}
private String getPriorityEmoji(TicketPriority priority) {
return switch (priority) {
case LOW -> "🟢";
case NORMAL -> "🟡";
case HIGH -> "🟠";
case URGENT -> "🔴";
};
}
private void sendAsync(String webhookUrl, String json) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
URL url = new URL(webhookUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("User-Agent", "TicketSystem-Plugin");
conn.setDoOutput(true);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
try (OutputStream os = conn.getOutputStream()) {
os.write(json.getBytes(StandardCharsets.UTF_8));
}
int responseCode = conn.getResponseCode();
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][DiscordWebhook] Response: " + responseCode);
if (responseCode != 200 && responseCode != 204) {
plugin.getLogger().info("[DEBUG][DiscordWebhook] Payload: " + json);
}
}
if (responseCode != 200 && responseCode != 204) {
plugin.getLogger().warning("[DiscordWebhook] Unerwarteter Response-Code: " + responseCode);
}
conn.disconnect();
} catch (Exception e) {
plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage());
if (plugin.isDebug()) e.printStackTrace();
}
});
}
}

View File

@@ -0,0 +1,578 @@
package de.ticketsystem.gui;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.FaqEntry;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.profile.PlayerProfile;
import org.bukkit.profile.PlayerTextures;
import java.net.URL;
import java.util.*;
/**
* FAQ GUI für Spieler (Lesemodus) und Admins (Verwaltungsmodus).
* Layout, Größe und Slots sind über config.yml steuerbar.
*/
public class FaqGUI implements Listener {
// ── Konfigurierbare Felder ────────────────────────────────────────────────
private int faqRows;
private int faqNavPrev, faqNavNext, faqNavAdd, faqNavPage;
private Material headMaterial = Material.PLAYER_HEAD;
private String headTexture = "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e";
private List<Integer> contentSlots = new ArrayList<>();
// ── System Felder ────────────────────────────────────────────────────────
private static final String DEFAULT_SKIN_URL =
"http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e";
private final TicketPlugin plugin;
private final Map<UUID, Map<Integer, FaqEntry>> slotMap = new HashMap<>();
private final Map<UUID, Integer> faqPage = new HashMap<>();
private final Set<UUID> adminView = new HashSet<>();
private final Map<UUID, FaqEntry> actionEntry = new HashMap<>();
private final Map<UUID, String> awaitingQuestion = new HashMap<>();
private final Map<UUID, String> awaitingAnswer = new HashMap<>();
// Materialien für Navigations-Buttons
private Material matNavPrev, matNavNext, matNavPage, matNavAdd;
public FaqGUI(TicketPlugin plugin) {
this.plugin = plugin;
reloadConfig();
}
/**
* Berechnet die Item-Slots:
* Schachbrett-Muster ohne Glas passt sich automatisch an jede Größe an:
*
* Reihe 0 : Glas in geraden Spalten (0,2,4,6,8), Items in ungeraden (1,3,5,7) → 4 Items
* Reihe 1 : Items in geraden Spalten (0,2,4,6,8), leer in ungeraden → 5 Items
* Reihe 2 : Items in ungeraden Spalten (1,3,5,7), leer in geraden → 4 Items
* Reihe 3 : Items in geraden Spalten (0,2,4,6,8), leer in ungeraden → 5 Items
* ... (Reihe 1+ kein Glas nur Items und leere Slots wechselnd)
* Letzte Reihe: Navigation (Footer)
*
* Kapazität:
* 4 Reihen → 13 Items | 5 Reihen → 18 Items | 6 Reihen → 22 Items
*/
private List<Integer> buildPatternSlots(int rows) {
List<Integer> slots = new ArrayList<>();
int contentRows = rows - 1; // Letzte Reihe = Navigation
for (int row = 0; row < contentRows; row++) {
int base = row * 9;
if (row % 2 == 0) {
// Gerade Reihen (0,2,4,...): Items in geraden Spalten
slots.add(base + 0);
slots.add(base + 2);
slots.add(base + 4);
slots.add(base + 6);
slots.add(base + 8);
} else {
// Ungerade Reihen (1,3,5,...): Items in ungeraden Spalten
slots.add(base + 1);
slots.add(base + 3);
slots.add(base + 5);
slots.add(base + 7);
}
}
return slots;
}
/**
* Setzt die Navigations-Slot-Defaults passend zur aktuellen Zeilenanzahl.
* Nav-Leiste liegt immer in der letzten Reihe.
*/
/**
* Nav-Leiste liegt in der letzten Reihe.
* Gerade Spalten werden durch fillNavBar bereits mit Glas gefüllt.
* Buttons liegen auf ungerade Spalten passend zum Content-Muster:
* Slot +1 = Prev | Slot +3 = Add | Slot +5 = Page | Slot +7 = Next
*/
private void applyNavDefaults(int rows) {
int navBase = (rows - 1) * 9; // Erster Slot der letzten Reihe
faqNavPrev = navBase + 1; // 2. Slot (ungerade) Blättern zurück
faqNavAdd = navBase + 3; // 4. Slot (ungerade) Hinzufügen
faqNavPage = navBase + 5; // 6. Slot (ungerade) Seitenanzeige
faqNavNext = navBase + 7; // 8. Slot (ungerade) Blättern vor
}
/**
* Lädt die Konfiguration sicher mit Fallback-Werten.
*/
public void reloadConfig() {
// ── 1. STANDARDWERTE SETZEN (Sicherheit gegen leere Config) ─────────
faqRows = 6;
headMaterial = Material.PLAYER_HEAD;
headTexture = DEFAULT_SKIN_URL;
matNavPrev = Material.ARROW;
matNavNext = Material.ARROW;
matNavPage = Material.PAPER;
matNavAdd = Material.LIME_WOOL;
// Standard Content-Slots nach Muster: Glasscheiben in Spalte 0, 4, 8
contentSlots = buildPatternSlots(faqRows);
applyNavDefaults(faqRows);
// ── 2. VERSUCHEN AUS CONFIG ZU LADEN ─────────────────────────────────
ConfigurationSection guiSettings = plugin.getConfig().getConfigurationSection("gui-settings");
if (guiSettings != null) {
ConfigurationSection faqConf = guiSettings.getConfigurationSection("faq");
if (faqConf != null) {
// Rows laden
faqRows = faqConf.getInt("rows", 6);
if (faqRows < 4) faqRows = 4; // Minimum 4 Reihen
if (faqRows > 6) faqRows = 6;
// Nav-Defaults für die gewählte Zeilenanzahl setzen
applyNavDefaults(faqRows);
// Content Slots laden nur überschreiben wenn explizit in config gesetzt
if (faqConf.contains("content-slots") && !faqConf.getIntegerList("content-slots").isEmpty()) {
contentSlots = faqConf.getIntegerList("content-slots");
} else {
// Muster für die konfigurierte Zeilenanzahl neu berechnen
contentSlots = buildPatternSlots(faqRows);
}
// Navigation Slots laden (Config überschreibt dynamische Defaults)
faqNavPrev = getSlot(faqConf, "nav.prev", faqNavPrev, faqRows);
faqNavNext = getSlot(faqConf, "nav.next", faqNavNext, faqRows);
faqNavPage = getSlot(faqConf, "nav.page", faqNavPage, faqRows);
faqNavAdd = getSlot(faqConf, "nav.add", faqNavAdd, faqRows);
// Head Config laden
ConfigurationSection headConf = faqConf.getConfigurationSection("head-item");
if (headConf != null) {
headMaterial = getMaterial(headConf, "material", Material.PLAYER_HEAD);
headTexture = headConf.getString("texture", DEFAULT_SKIN_URL);
}
}
// Materialien laden (Global)
ConfigurationSection itemsSettings = guiSettings.getConfigurationSection("items");
if (itemsSettings != null) {
matNavPrev = getMaterial(itemsSettings, "nav-prev", Material.ARROW);
matNavNext = getMaterial(itemsSettings, "nav-next", Material.ARROW);
matNavPage = getMaterial(itemsSettings, "nav-page", Material.PAPER);
matNavAdd = getMaterial(itemsSettings, "nav-add", Material.LIME_WOOL);
}
}
}
private int getSlot(ConfigurationSection section, String path, int def, int rows) {
int val = section.getInt(path, def);
int max = rows * 9;
if (val >= 0 && val < max) return val;
// Slot liegt außerhalb des Inventars → Spalte beibehalten, letzte Reihe verwenden
int col = (val >= 0 ? val : def) % 9;
return (rows - 1) * 9 + col;
}
private Material getMaterial(ConfigurationSection section, String path, Material def) {
try {
return Material.valueOf(section.getString(path, def.name()));
} catch (IllegalArgumentException e) {
return def;
}
}
// ── Hilfsmethode für Sprachzugriff ─────────────────────────────────────────
private String f(String key) {
return plugin.lang().get("gui.faq." + key);
}
private String f(String key, String... replacements) {
return plugin.lang().format("gui.faq." + key, replacements);
}
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC OPEN-METHODEN
// ═══════════════════════════════════════════════════════════════════════
public void openFaqGUI(Player player) {
openFaqGUI(player, faqPage.getOrDefault(player.getUniqueId(), 0));
}
public void openFaqGUI(Player player, int page) {
boolean isAdmin = player.hasPermission("ticket.admin");
String title = isAdmin ? f("admin-title") : f("title");
List<FaqEntry> all = plugin.getFaqManager().getAll();
// Sicherheit gegen leere Content-Slots Liste (Division by Zero)
int pageSize = contentSlots.isEmpty() ? 45 : contentSlots.size();
int totalPages = Math.max(1, (int) Math.ceil((double) all.size() / pageSize));
page = Math.max(0, Math.min(page, totalPages - 1));
faqPage.put(player.getUniqueId(), page);
int invSize = faqRows * 9;
Inventory inv = Bukkit.createInventory(null, invSize, title);
Map<Integer, FaqEntry> sm = new HashMap<>();
int start = page * pageSize;
int itemsOnCurrentPage = 0; // Zähler für Items auf dieser Seite
// Wir nutzen entweder die konfigurierten Slots oder sequentiell von 0, falls Liste leer
for (int i = 0; i < pageSize && (start + i) < all.size(); i++) {
FaqEntry entry = all.get(start + i);
int slot = contentSlots.isEmpty() ? i : contentSlots.get(i);
if (slot < invSize) {
inv.setItem(slot, buildFaqItem(entry, isAdmin));
sm.put(slot, entry);
itemsOnCurrentPage++;
}
}
slotMap.put(player.getUniqueId(), sm);
if (isAdmin) adminView.add(player.getUniqueId());
else adminView.remove(player.getUniqueId());
// Kein Glas im Content-Bereich nur Footer (fillNavBar) hat Glasscheiben
// Übergeben der korrekten Anzahl an die Navigationsleiste
fillNavBar(inv, page, totalPages, isAdmin, all.isEmpty(), itemsOnCurrentPage);
player.openInventory(inv);
}
// ═══════════════════════════════════════════════════════════════════════
// CLICK-EVENTS
// ═══════════════════════════════════════════════════════════════════════
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
String title = event.getView().getTitle();
String playerTitle = f("title");
String adminTitle = f("admin-title");
String actionTitle = f("action-title");
if (!title.equals(playerTitle) && !title.equals(adminTitle)
&& !title.equals(actionTitle)) return;
event.setCancelled(true);
int slot = event.getRawSlot();
if (slot < 0) return;
if (title.equals(actionTitle)) {
FaqEntry entry = actionEntry.get(player.getUniqueId());
if (entry == null) return;
switch (slot) {
case 10 -> startEditFlow(player, entry);
case 12 -> deleteFaq(player, entry);
case 16 -> openFaqGUI(player);
}
return;
}
boolean isAdmin = adminView.contains(player.getUniqueId());
int curPage = faqPage.getOrDefault(player.getUniqueId(), 0);
if (slot == faqNavPrev) { openFaqGUI(player, curPage - 1); return; }
if (slot == faqNavNext) { openFaqGUI(player, curPage + 1); return; }
if (slot == faqNavAdd && isAdmin) { startAddFlow(player); return; }
// Check ob Slot in contentSlots ist
if (contentSlots.contains(slot)) {
Map<Integer, FaqEntry> sm = slotMap.get(player.getUniqueId());
if (sm == null) return;
FaqEntry entry = sm.get(slot);
if (entry == null) return;
if (isAdmin) {
openActionGUI(player, entry);
} else {
player.closeInventory();
player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(plugin.lang().format("faq.list-entry",
"{id}", String.valueOf(entry.getId()), "{question}", entry.getQuestion()));
player.sendMessage(plugin.lang().color("§f" + entry.getAnswer()));
player.sendMessage(plugin.lang().get("general.separator"));
}
}
}
// ─────────────────────────── Admin-Aktions-GUI ─────────────────────────
private void openActionGUI(Player player, FaqEntry entry) {
actionEntry.put(player.getUniqueId(), entry);
Inventory inv = Bukkit.createInventory(null, 27, f("action-title"));
inv.setItem(4, buildFaqItem(entry, false));
inv.setItem(10, buildItem(Material.WRITABLE_BOOK, f("edit-button"),
List.of(f("edit-lore-1"), f("edit-lore-2"))));
inv.setItem(12, buildItem(Material.BARRIER, f("delete-button"),
List.of(f("delete-lore-1"), f("delete-lore-2"))));
inv.setItem(16, buildItem(Material.ARROW, f("back-button"),
List.of(f("back-lore"))));
fillGlass(inv);
player.openInventory(inv);
}
// ─────────────────────────── Chat-Flow: Hinzufügen ─────────────────────
private void startAddFlow(Player player) {
player.closeInventory();
awaitingQuestion.put(player.getUniqueId(), "new");
player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(f("chat-create-title"));
player.sendMessage(f("chat-question-prompt"));
player.sendMessage(plugin.lang().get("general.separator"));
}
// ─────────────────────────── Chat-Flow: Bearbeiten ─────────────────────
private void startEditFlow(Player player, FaqEntry entry) {
player.closeInventory();
awaitingQuestion.put(player.getUniqueId(), "edit:" + entry.getId());
player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(f("chat-edit-title", "{id}", String.valueOf(entry.getId())));
player.sendMessage(f("chat-current-question", "{question}", entry.getQuestion()));
player.sendMessage(f("chat-question-prompt"));
player.sendMessage(plugin.lang().get("general.separator"));
}
// ─────────────────────────── Löschen ───────────────────────────────────
private void deleteFaq(Player player, FaqEntry entry) {
player.closeInventory();
boolean success = plugin.getFaqManager().delete(entry.getId());
if (success) {
player.sendMessage(plugin.lang().format("faq.deleted", "{id}", String.valueOf(entry.getId())));
} else {
player.sendMessage(f("delete-error", "{id}", String.valueOf(entry.getId())));
}
openFaqGUI(player);
}
// ═══════════════════════════════════════════════════════════════════════
// CHAT-EVENTS
// ═══════════════════════════════════════════════════════════════════════
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerChat(AsyncPlayerChatEvent event) {
Player player = event.getPlayer();
UUID uuid = player.getUniqueId();
// ── Schritt 1: Warte auf Frage ─────────────────────────────────────
if (awaitingQuestion.containsKey(uuid)) {
event.setCancelled(true);
String state = awaitingQuestion.remove(uuid);
String input = event.getMessage().trim();
if (input.equalsIgnoreCase("cancel")) {
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.lang().get("gui.close-cancelled"));
openFaqGUI(player);
});
return;
}
awaitingAnswer.put(uuid, state + "\u0000" + input);
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(f("question-set", "{question}", input));
player.sendMessage(f("chat-answer-prompt"));
});
return;
}
// ── Schritt 2: Warte auf Antwort ───────────────────────────────────
if (awaitingAnswer.containsKey(uuid)) {
event.setCancelled(true);
String stateAndQuestion = awaitingAnswer.remove(uuid);
String input = event.getMessage().trim();
if (input.equalsIgnoreCase("cancel")) {
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.lang().get("gui.close-cancelled"));
openFaqGUI(player);
});
return;
}
int sep = stateAndQuestion.indexOf("\u0000");
String state = stateAndQuestion.substring(0, sep);
String question = stateAndQuestion.substring(sep + 1);
Bukkit.getScheduler().runTask(plugin, () -> {
if (state.equals("new")) {
FaqEntry created = plugin.getFaqManager().add(question, input);
player.sendMessage(plugin.lang().format("faq.created", "{id}", String.valueOf(created.getId())));
} else {
int id;
try {
id = Integer.parseInt(state.substring(5));
} catch (NumberFormatException e) {
player.sendMessage(f("internal-error"));
openFaqGUI(player);
return;
}
boolean ok = plugin.getFaqManager().edit(id, question, input);
if (ok) player.sendMessage(plugin.lang().format("faq.updated", "{id}", String.valueOf(id)));
else player.sendMessage(plugin.lang().format("faq.not-found", "{id}", String.valueOf(id)));
}
openFaqGUI(player);
});
}
}
// ═══════════════════════════════════════════════════════════════════════
// ITEM-BUILDER
// ═══════════════════════════════════════════════════════════════════════
private ItemStack buildFaqItem(FaqEntry entry, boolean adminHint) {
ItemStack item;
if (headMaterial == Material.PLAYER_HEAD) {
try {
item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
if (meta != null) {
PlayerProfile profile = Bukkit.createPlayerProfile(
UUID.nameUUIDFromBytes(("FAQ_" + entry.getId()).getBytes()), "FAQ_" + entry.getId());
PlayerTextures textures = profile.getTextures();
textures.setSkin(new URL(headTexture));
profile.setTextures(textures);
meta.setOwnerProfile(profile);
meta.setDisplayName("§e§l" + entry.getQuestion());
meta.setLore(buildFaqLore(entry, adminHint));
item.setItemMeta(meta);
}
} catch (Exception e) {
item = buildFallbackItem(entry, adminHint);
}
} else {
item = new ItemStack(headMaterial);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName("§e§l" + entry.getQuestion());
meta.setLore(buildFaqLore(entry, adminHint));
item.setItemMeta(meta);
}
}
return item;
}
private ItemStack buildFallbackItem(FaqEntry entry, boolean adminHint) {
ItemStack item = new ItemStack(Material.BOOK);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName("§e§l" + entry.getQuestion());
meta.setLore(buildFaqLore(entry, adminHint));
item.setItemMeta(meta);
}
return item;
}
private List<String> buildFaqLore(FaqEntry entry, boolean adminHint) {
List<String> lore = new ArrayList<>();
lore.add(f("lore-separator"));
lore.add(f("lore-id", "{id}", String.valueOf(entry.getId())));
lore.add(f("lore-separator"));
String answer = entry.getAnswer();
int chunkSize = 40;
for (int i = 0; i < answer.length(); i += chunkSize) {
int end = Math.min(i + chunkSize, answer.length());
if (end < answer.length() && answer.charAt(end) != ' ') {
int lastSpace = answer.lastIndexOf(' ', end);
if (lastSpace > i) end = lastSpace;
}
lore.add("§f" + answer.substring(i, end).trim());
i = end - chunkSize;
}
lore.add(f("lore-separator"));
if (adminHint) lore.add(f("click-edit"));
else lore.add(f("click-detail"));
return lore;
}
private ItemStack buildItem(Material material, String displayName, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName(displayName);
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
// ─────────────────────────── Navigationsleiste ─────────────────────────
private void fillNavBar(Inventory inv, int page, int totalPages, boolean isAdmin, boolean isEmpty, int itemCount) {
ItemStack glass = makeGlass();
// Untere Reihe füllen (Nav-Bar)
for (int i = inv.getSize() - 9; i < inv.getSize(); i++) inv.setItem(i, glass);
if (page > 0) {
inv.setItem(faqNavPrev, buildActionItem(matNavPrev,
f("nav-prev"),
List.of(f("nav-prev-lore", "{page}", String.valueOf(page), "{total}", String.valueOf(totalPages)))));
}
if (page < totalPages - 1) {
inv.setItem(faqNavNext, buildActionItem(matNavNext,
f("nav-next"),
List.of(f("nav-next-lore", "{page}", String.valueOf(page + 2), "{total}", String.valueOf(totalPages)))));
}
// itemCount ist jetzt die tatsächliche Anzahl der Items auf der Seite
int displayCount = isEmpty ? 0 : itemCount;
inv.setItem(faqNavPage, buildActionItem(matNavPage, f("nav-page", "{page}", String.valueOf(page + 1), "{total}", String.valueOf(totalPages)),
List.of(f("nav-page-lore", "{count}", String.valueOf(displayCount)))));
if (isAdmin) {
inv.setItem(faqNavAdd, buildActionItem(matNavAdd, f("add-button"),
List.of(f("add-lore-1"), f("add-lore-2"))));
}
}
private void fillGlass(Inventory inv) {
ItemStack glass = makeGlass();
for (int i = 0; i < inv.getSize(); i++) {
if (inv.getItem(i) == null) inv.setItem(i, glass);
}
}
private ItemStack makeGlass() {
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = glass.getItemMeta();
if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); }
return glass;
}
private ItemStack buildActionItem(Material material, String displayName, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName(displayName);
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,14 @@
package de.ticketsystem.listeners; package de.ticketsystem.listeners;
import java.util.List;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.Ticket;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
@@ -19,21 +26,101 @@ public class PlayerJoinListener implements Listener {
public void onPlayerJoin(PlayerJoinEvent event) { public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();
// Nur Supporter und Admins erhalten die Join-Benachrichtigung // ── Supporter/Admin: offene Tickets anzeigen ──────────────────────
if (!player.hasPermission("ticket.support") && !player.hasPermission("ticket.admin")) return; if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int count = plugin.getDatabaseManager().countOpenTickets();
if (count > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
player.sendMessage(plugin.lang().format("join.open-tickets",
"{count}", String.valueOf(count)));
player.sendMessage(plugin.lang().get("join.open-tickets-hint"));
}, 40L);
}
});
}
// Verzögerung von 2 Sekunden damit die Join-Sequenz abgeschlossen ist // ── BungeeCord: ausstehenden Teleport-Auftrag prüfen ─────────────
if (plugin.isBungeeCordEnabled()
&& plugin.getConfig().getBoolean("bungee-teleport-enabled", true)) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
DatabaseManager.PendingTeleport pt =
plugin.getDatabaseManager().consumePendingTeleport(player.getUniqueId());
if (pt == null) return;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
World world = Bukkit.getWorld(pt.world());
if (world == null) {
player.sendMessage(plugin.lang().format("join.teleport-world-missing",
"{world}", pt.world()));
return;
}
Location loc = new Location(world, pt.x(), pt.y(), pt.z(), pt.yaw(), pt.pitch());
player.teleport(loc);
String coords = String.format("%.0f, %.0f, %.0f", pt.x(), pt.y(), pt.z());
player.sendMessage(plugin.lang().format("join.teleport-success", "{coords}", coords));
});
});
}, 40L);
}
// ── Ausstehende Kommentar-/Schließ-Benachrichtigungen anzeigen ────
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<String> pending = plugin.getDatabaseManager().getPendingNotifications(player.getUniqueId());
if (pending.isEmpty()) return;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(plugin.lang().get("join.pending-header"));
for (String msg : pending) {
player.sendMessage(plugin.lang().color(msg));
}
player.sendMessage(plugin.lang().get("general.separator"));
});
plugin.getDatabaseManager().clearPendingNotifications(player.getUniqueId());
});
}, 60L);
// ── Spieler: Ticket-claimed-Benachrichtigung für Offline-Zeit ──────
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
plugin.getTicketManager().notifyClaimedWhileOffline(player);
}, 60L);
// ── Spieler: über geschlossene Tickets informieren ────────────────
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int count = plugin.getDatabaseManager().countOpenTickets(); // Nur Tickets dieses Spielers laden (nicht ALLE closed Tickets)
List<Ticket> closed = plugin.getDatabaseManager()
.getUnnotifiedClosedTicketsByPlayer(player.getUniqueId());
if (count > 0) { for (Ticket t : closed) {
Bukkit.getScheduler().runTaskLater(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () ->
String msg = plugin.formatMessage("messages.join-open-tickets") plugin.getTicketManager().notifyCreatorClosed(t));
.replace("{count}", String.valueOf(count));
player.sendMessage(msg);
player.sendMessage(plugin.color("&7» Tippe &e/ticket list &7für die Übersicht."));
}, 40L); // 40 Ticks = 2 Sekunden
} }
}); });
// ── Update-Hinweis für OPs/Admins ────────────────────────────────
if (player.isOp() || player.hasPermission("ticket.admin")) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
int resourceId = 132757;
new de.ticketsystem.UpdateChecker(plugin, resourceId).getVersion(version -> {
String current = plugin.getDescription().getVersion();
if (!current.equals(version)) {
String bar = plugin.lang().get("update.available-bar");
String line1 = plugin.lang().format("update.available-line1", "{version}", version);
String line2 = plugin.lang().get("update.available-line2");
player.sendMessage(ChatColor.GOLD + bar);
player.sendMessage(line1);
player.sendMessage(line2);
player.sendMessage(ChatColor.GOLD + bar);
}
});
}, 20L);
}
} }
} }

View File

@@ -0,0 +1,177 @@
package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Loads and manages ticket categories defined in config.yml under the "categories" section.
*
* Example config.yml layout:
*
* categories:
* general:
* name: "Allgemein"
* color: "&7"
* material: "PAPER"
* aliases:
* - "allgemein"
* - "general"
* bug:
* name: "Bug"
* color: "&c"
* material: "REDSTONE"
* aliases:
* - "bug"
* - "fehler"
*/
public class CategoryManager {
private final TicketPlugin plugin;
/** Ordered map: key → ConfigCategory */
private final Map<String, ConfigCategory> categories = new LinkedHashMap<>();
/** Alias → key mapping for fast resolve() lookups */
private final Map<String, String> aliasMap = new LinkedHashMap<>();
public CategoryManager(TicketPlugin plugin) {
this.plugin = plugin;
load();
}
// ─────────────────────────── Loading ───────────────────────────────────
private void load() {
categories.clear();
aliasMap.clear();
ConfigurationSection section = plugin.getConfig().getConfigurationSection("categories");
if (section == null || section.getKeys(false).isEmpty()) {
// Fallback: create built-in defaults so the plugin always works
loadDefaults();
return;
}
for (String key : section.getKeys(false)) {
ConfigurationSection cat = section.getConfigurationSection(key);
if (cat == null) continue;
String name = cat.getString("name", capitalize(key));
String color = cat.getString("color", "&7");
String matStr = cat.getString("material", "PAPER").toUpperCase();
Material material;
try {
material = Material.valueOf(matStr);
} catch (IllegalArgumentException e) {
plugin.getLogger().warning("[CategoryManager] Unbekanntes Material '" + matStr
+ "' für Kategorie '" + key + "'. Fallback: PAPER");
material = Material.PAPER;
}
ConfigCategory category = new ConfigCategory(key, name, color, material);
categories.put(key.toLowerCase(), category);
// Register key itself as alias
aliasMap.put(key.toLowerCase(), key.toLowerCase());
// Register additional aliases
List<String> aliases = cat.getStringList("aliases");
for (String alias : aliases) {
aliasMap.put(alias.toLowerCase(), key.toLowerCase());
}
}
if (categories.isEmpty()) {
plugin.getLogger().warning("[CategoryManager] Keine gültigen Kategorien in der config.yml gefunden. Lade Standardkategorien.");
loadDefaults();
} else {
plugin.getLogger().info("[CategoryManager] " + categories.size() + " Kategorie(n) geladen: " + String.join(", ", categories.keySet()));
}
}
/** Built-in fallback categories — mirrors the old TicketCategory enum */
private void loadDefaults() {
addDefault("general", "Allgemein", "&7", Material.PAPER, "allgemein", "general");
addDefault("bug", "Bug", "&c", Material.REDSTONE, "bug", "fehler");
addDefault("question", "Frage", "&e", Material.BOOK, "frage", "question");
addDefault("complaint", "Beschwerde", "&6", Material.WRITABLE_BOOK, "beschwerde", "complaint");
addDefault("other", "Sonstiges", "&8", Material.FEATHER, "sonstiges", "other");
plugin.getLogger().info("[CategoryManager] Standard-Kategorien geladen (5).");
}
private void addDefault(String key, String name, String color, Material mat, String... aliases) {
ConfigCategory cat = new ConfigCategory(key, name, color, mat);
categories.put(key, cat);
aliasMap.put(key, key);
for (String alias : aliases) aliasMap.put(alias.toLowerCase(), key);
}
// ─────────────────────────── Public API ────────────────────────────────
/**
* Returns all loaded categories in config order.
*/
public List<ConfigCategory> getAll() {
return Collections.unmodifiableList(new ArrayList<>(categories.values()));
}
/**
* Returns the first category (default), or a hard-coded fallback if empty.
*/
public ConfigCategory getDefault() {
if (categories.isEmpty()) return new ConfigCategory("general", "Allgemein", "&7", Material.PAPER);
return categories.values().iterator().next();
}
/**
* Looks up a category by exact key (case-insensitive).
* Returns null if not found.
*/
public ConfigCategory fromKey(String key) {
if (key == null) return getDefault();
ConfigCategory cat = categories.get(key.toLowerCase());
return cat != null ? cat : getDefault();
}
/**
* Resolves a user-supplied string (key or alias) to a ConfigCategory.
* Returns null if no match is found (so callers can show an error).
*/
public ConfigCategory resolve(String input) {
if (input == null) return null;
String key = aliasMap.get(input.toLowerCase());
return key != null ? categories.get(key) : null;
}
/**
* Returns a human-readable comma-separated list of all category keys,
* e.g. "general, bug, question, complaint, other"
*/
public String getAvailableNames() {
return String.join(", ", categories.keySet());
}
/**
* Reloads categories from the (already reloaded) config.
* Call this after plugin.reloadConfig().
*/
public void reload() {
load();
}
// ─────────────────────────── Helpers ───────────────────────────────────
private static String capitalize(String s) {
if (s == null || s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
}
}

View File

@@ -0,0 +1,181 @@
package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.FaqEntry;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* Manages FAQ entries stored in faqs.yml.
*
* Admins can add, edit and delete FAQs in-game.
* All changes are saved immediately to faqs.yml.
*
* faqs.yml layout:
*
* faqs:
* 1:
* question: "Wie erstelle ich ein Ticket?"
* answer: "Nutze /ticket create [Kategorie] [Beschreibung]."
* 2:
* question: "..."
* answer: "..."
*/
public class FaqManager {
private final TicketPlugin plugin;
private final File faqFile;
private YamlConfiguration faqConfig;
private final List<FaqEntry> entries = new ArrayList<>();
private int nextId = 1;
public FaqManager(TicketPlugin plugin) {
this.plugin = plugin;
this.faqFile = new File(plugin.getDataFolder(), "faqs.yml");
load();
}
// ─────────────────────────── Loading & Saving ───────────────────────────
private void load() {
entries.clear();
nextId = 1;
if (!faqFile.exists()) {
try {
faqFile.getParentFile().mkdirs();
faqFile.createNewFile();
} catch (IOException e) {
plugin.getLogger().severe("[FaqManager] Konnte faqs.yml nicht erstellen: " + e.getMessage());
}
faqConfig = new YamlConfiguration();
loadDefaults();
save();
return;
}
faqConfig = YamlConfiguration.loadConfiguration(faqFile);
ConfigurationSection section = faqConfig.getConfigurationSection("faqs");
if (section != null) {
for (String key : section.getKeys(false)) {
try {
int id = Integer.parseInt(key);
String question = faqConfig.getString("faqs." + key + ".question", "");
String answer = faqConfig.getString("faqs." + key + ".answer", "");
if (!question.isBlank() && !answer.isBlank()) {
entries.add(new FaqEntry(id, question, answer));
if (id >= nextId) nextId = id + 1;
}
} catch (NumberFormatException ignored) {}
}
}
entries.sort(Comparator.comparingInt(FaqEntry::getId));
if (plugin.isDebug()) {
plugin.getLogger().info("[FaqManager] " + entries.size() + " FAQ(s) geladen.");
}
}
/** Writes the example FAQs into a freshly created faqs.yml. */
private void loadDefaults() {
writeEntry(1, "Wie erstelle ich ein Ticket?",
"Nutze den Befehl /ticket create [Kategorie] [Prio][Beschreibung] um ein neues Ticket zu erstellen.");
writeEntry(2, "Wie lange dauert die Bearbeitung?",
"Unser Support-Team bearbeitet Tickets so schnell wie möglich. Bitte habe etwas Geduld.");
writeEntry(3, "Kann ich mein Ticket löschen?",
"Ja! Öffne /ticket list und klicke auf dein Ticket, um es aus der Übersicht zu entfernen.");
writeEntry(4, "Wie kann ich meinen Support bewerten?",
"Nach dem Schließen eines Tickets kannst du mit /ticket rate <ID> good/bad eine Bewertung abgeben.");
nextId = 5;
// Sync entries list with what we just wrote Text muss identisch sein!
entries.add(new FaqEntry(1, "Wie erstelle ich ein Ticket?",
"Nutze den Befehl /ticket create [Kategorie] [Prio][Beschreibung] um ein neues Ticket zu erstellen."));
entries.add(new FaqEntry(2, "Wie lange dauert die Bearbeitung?",
"Unser Support-Team bearbeitet Tickets so schnell wie möglich. Bitte habe etwas Geduld."));
entries.add(new FaqEntry(3, "Kann ich mein Ticket löschen?",
"Ja! Öffne /ticket list und klicke auf dein Ticket, um es aus der Übersicht zu entfernen."));
entries.add(new FaqEntry(4, "Wie kann ich meinen Support bewerten?",
"Nach dem Schließen eines Tickets kannst du mit /ticket rate <ID> good/bad eine Bewertung abgeben."));
}
private void writeEntry(int id, String question, String answer) {
faqConfig.set("faqs." + id + ".question", question);
faqConfig.set("faqs." + id + ".answer", answer);
}
private void save() {
try {
faqConfig.save(faqFile);
} catch (IOException e) {
plugin.getLogger().severe("[FaqManager] Konnte faqs.yml nicht speichern: " + e.getMessage());
}
}
// ─────────────────────────── Public API ────────────────────────────────
/** Returns an unmodifiable view of all FAQ entries in ID order. */
public List<FaqEntry> getAll() {
return Collections.unmodifiableList(entries);
}
/** Looks up an entry by its numeric ID. Returns null if not found. */
public FaqEntry getById(int id) {
return entries.stream().filter(e -> e.getId() == id).findFirst().orElse(null);
}
/**
* Adds a new FAQ entry and saves immediately.
*
* @param question The question text.
* @param answer The answer text.
* @return The newly created {@link FaqEntry}.
*/
public FaqEntry add(String question, String answer) {
int id = nextId++;
FaqEntry entry = new FaqEntry(id, question, answer);
entries.add(entry);
writeEntry(id, question, answer);
save();
return entry;
}
/**
* Edits an existing FAQ entry and saves immediately.
*
* @return true if the entry was found and updated, false otherwise.
*/
public boolean edit(int id, String question, String answer) {
FaqEntry entry = getById(id);
if (entry == null) return false;
entry.setQuestion(question);
entry.setAnswer(answer);
writeEntry(id, question, answer);
save();
return true;
}
/**
* Deletes a FAQ entry and saves immediately.
*
* @return true if the entry was found and deleted, false otherwise.
*/
public boolean delete(int id) {
FaqEntry entry = getById(id);
if (entry == null) return false;
entries.remove(entry);
faqConfig.set("faqs." + id, null);
save();
return true;
}
/** Reloads FAQs from faqs.yml without restarting the server. */
public void reload() {
load();
}
}

View File

@@ -0,0 +1,346 @@
package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Lädt alle Plugin-Texte aus der aktiven Sprachdatei (lang_de.yml / lang_en.yml)
* und ersetzt {cmd_X}-Platzhalter durch die passenden Befehlsnamen.
*
* Unterstützt Hex-Farbcodes (z.B. &#FF0055 oder <#FF0055>).
* Funktioniert ab Spigot 1.16+.
*
* ┌─────────────────────────────────────────────────────────┐
* │ Einziger Konfigurations-Schlüssel: language │
* │ │
* │ language: de → deutsche Texte + deutsche Befehle │
* │ language: en → englische Texte + englische Befehle │
* │ language: both → deutsche Texte + beide Befehlsnamen │
* │ │
* │ "command-language" existiert nicht mehr und wird │
* │ vollständig ignoriert. │
* └─────────────────────────────────────────────────────────┘
*
* Verfügbare {cmd_X}-Platzhalter in lang.yml:
* {cmd_create} {cmd_list} {cmd_comment} {cmd_rate}
* {cmd_claim} {cmd_close} {cmd_forward} {cmd_reload}
* {cmd_stats} {cmd_archive} {cmd_migrate} {cmd_export}
* {cmd_import} {cmd_blacklist} {cmd_setpriority} {cmd_faq} {cmd_top}
*/
public class LanguageManager {
// ── Konstanten ──────────────────────────────────────────────────────────
private static final Set<String> SUPPORTED = Set.of("de", "en", "both");
private static final String FALLBACK = "de";
/** Nachrichten-Pfade die KEINEN Plugin-Prefix erhalten. */
private static final String[] NO_PREFIX_PATHS = {
"general.separator", "help.", "stats.", "top.", "faq.list-",
"blacklist.list-", "gui.", "join.pending-header", "update."
};
// ── Befehlsnamen-Tabellen (statisch, ändern sich nie) ───────────────────
private static final LinkedHashMap<String, String> DE = new LinkedHashMap<>();
private static final LinkedHashMap<String, String> EN = new LinkedHashMap<>();
static {
DE.put("create", "erstellen");
DE.put("list", "liste");
DE.put("comment", "kommentar");
DE.put("rate", "bewerten");
DE.put("claim", "übernehmen");
DE.put("close", "schließen");
DE.put("forward", "weiterleiten");
DE.put("reload", "neuladen");
DE.put("stats", "statistik");
DE.put("archive", "archivieren");
DE.put("migrate", "migrieren");
DE.put("export", "exportieren");
DE.put("import", "importieren");
DE.put("blacklist", "sperrliste");
DE.put("setpriority", "priorität");
DE.put("faq", "faq");
DE.put("top", "top");
EN.put("create", "create");
EN.put("list", "list");
EN.put("comment", "comment");
EN.put("rate", "rate");
EN.put("claim", "claim");
EN.put("close", "close");
EN.put("forward", "forward");
EN.put("reload", "reload");
EN.put("stats", "stats");
EN.put("archive", "archive");
EN.put("migrate", "migrate");
EN.put("export", "export");
EN.put("import", "import");
EN.put("blacklist", "blacklist");
EN.put("setpriority", "setpriority");
EN.put("faq", "faq");
EN.put("top", "top");
}
// ── Felder ───────────────────────────────────────────────────────────────
private final TicketPlugin plugin;
private YamlConfiguration lang;
private String prefix;
/**
* Aktiver Sprachmodus wird bei jedem load() DIREKT aus der Config gelesen.
* Kein Cache, kein Zwischenwert. Immer frisch nach reloadConfig().
*/
private String activeLang;
/**
* Dateiname-Kürzel: "de" oder "en".
* "both" verwendet die DE-Datei für die Texte.
*/
private String fileLang;
/**
* Ersetzungsmap {cmd_X} → Anzeigename.
* Wird bei jedem load() komplett neu gebaut.
*/
private Map<String, String> cmdNames = new LinkedHashMap<>();
// ── Konstruktor ──────────────────────────────────────────────────────────
public LanguageManager(TicketPlugin plugin) {
this.plugin = plugin;
load();
}
// ── Laden ────────────────────────────────────────────────────────────────
/**
* Lädt (oder relädt) die Sprachdatei und baut alle Befehlsnamen neu.
* Muss nach plugin.reloadConfig() aufgerufen werden, damit die frische
* language-Einstellung übernommen wird.
*/
public void load() {
// 1. language aus der (bereits neu geladenen) Config lesen
String raw = plugin.getConfig().getString("language", FALLBACK)
.toLowerCase().trim();
if (!SUPPORTED.contains(raw)) {
plugin.getLogger().warning("[LanguageManager] Unbekannter Wert language='"
+ raw + "' in config.yml verwende '" + FALLBACK + "'.");
raw = FALLBACK;
}
activeLang = raw;
fileLang = "en".equals(activeLang) ? "en" : "de";
// 2. Sprachdatei einlesen (ggf. aus JAR extrahieren)
String fileName = "lang_" + fileLang + ".yml";
File file = new File(plugin.getDataFolder(), fileName);
if (!file.exists()) {
try {
plugin.saveResource(fileName, false);
} catch (IllegalArgumentException ex) {
plugin.getLogger().severe("[LanguageManager] '" + fileName
+ "' nicht im Plugin-JAR Plugin neu installieren!");
lang = new YamlConfiguration();
prefix = color("&8[&6Ticket&8] &r");
cmdNames = buildCmdNames();
return;
}
}
lang = YamlConfiguration.loadConfiguration(file);
// 3. Fehlende Schlüssel aus JAR-Defaults ergänzen & ggf. speichern
InputStream defaultStream = plugin.getResource(fileName);
if (defaultStream != null) {
YamlConfiguration defaults = YamlConfiguration.loadConfiguration(
new InputStreamReader(defaultStream, StandardCharsets.UTF_8));
lang.setDefaults(defaults);
boolean changed = false;
for (String key : defaults.getKeys(true)) {
if (!lang.isSet(key)) {
lang.set(key, defaults.get(key));
changed = true;
}
}
if (changed) {
try { lang.save(file); }
catch (IOException ex) {
plugin.getLogger().log(Level.WARNING,
"[LanguageManager] Konnte " + fileName + " nicht speichern.", ex);
}
}
}
// 4. Prefix & Befehlsnamen aufbauen
prefix = color(lang.getString("prefix", "&8[&6Ticket&8] &r"));
cmdNames = buildCmdNames();
plugin.getLogger().info("[LanguageManager] Geladen: " + fileName
+ " | language=" + activeLang
+ " | Befehle: " + describeMode());
}
// ── Befehlsnamen ─────────────────────────────────────────────────────────
/**
* Baut {cmd_X} → Anzeigename anhand von activeLang.
*
* de → /ticket erstellen
* en → /ticket create
* both → /ticket create §8(§7erstellen§8)
*/
private Map<String, String> buildCmdNames() {
Map<String, String> map = new LinkedHashMap<>();
for (String key : EN.keySet()) {
String display = switch (activeLang) {
case "en" -> "/ticket " + EN.get(key);
case "both" -> "/ticket " + EN.get(key) + " §8(§7" + DE.get(key) + "§8)";
default -> "/ticket " + DE.get(key); // "de" + alle unbekannten
};
map.put("{cmd_" + key + "}", display);
}
return map;
}
private String describeMode() {
return switch (activeLang) {
case "en" -> "Englisch (/ticket create ...)";
case "both" -> "Beides (/ticket create (erstellen) ...)";
default -> "Deutsch (/ticket erstellen ...)";
};
}
// ── Befehlssprache-Abfragen (für TicketCommand) ──────────────────────────
/** true wenn deutsche Subkommandos akzeptiert werden sollen (Tab-Complete & Eingabe). */
public boolean acceptsGerman() { return "de".equals(activeLang) || "both".equals(activeLang); }
/** true wenn englische Subkommandos akzeptiert werden sollen (Tab-Complete & Eingabe). */
public boolean acceptsEnglish() { return "en".equals(activeLang) || "both".equals(activeLang); }
// ── Interne Platzhalter-Ersetzung ───────────────────────────────────────
private String applyCmdNames(String text) {
if (text == null) return "";
for (Map.Entry<String, String> e : cmdNames.entrySet())
text = text.replace(e.getKey(), e.getValue());
return text;
}
// ── Public API ───────────────────────────────────────────────────────────
/** Roher Wert aus der Sprachdatei ohne Farbe oder Platzhalter-Ersetzung. */
public String getRaw(String key) {
String value = lang.getString(key);
if (value == null) {
plugin.getLogger().warning("[LanguageManager] Fehlender Schlüssel: " + key);
return key;
}
return value;
}
/** Übersetzter, eingefärbter Text. {cmd_X}-Platzhalter werden ersetzt. */
public String get(String key) {
return color(applyCmdNames(getRaw(key)));
}
/** Übersetzter Text mit zusätzlichen {placeholder} → Wert Ersetzungen. */
public String format(String key, String... replacements) {
String text = applyCmdNames(getRaw(key));
if (replacements.length % 2 != 0)
plugin.getLogger().warning("[LanguageManager] format() benötigt eine gerade Anzahl an Argumenten für: " + key);
for (int i = 0; i + 1 < replacements.length; i += 2)
text = text.replace(replacements[i], replacements[i + 1]);
return color(text);
}
/** Gibt prefix + format(...) zurück. */
public String formatWithPrefix(String key, String... replacements) {
return prefix + format(key, replacements);
}
/** Sendet eine Nachricht (mit Prefix wenn nötig) an einen CommandSender. */
public void send(CommandSender sender, String key, String... replacements) {
sender.sendMessage(needsPrefix(key)
? prefix + format(key, replacements)
: format(key, replacements));
}
/** Sendet die Trennlinie. */
public void sendSeparator(CommandSender sender) {
sender.sendMessage(get("general.separator"));
}
/**
* Gibt den vollständigen Befehlsstring zurück.
* Beispiel: getCmdName("create") → "/ticket create" bei language=en
*/
public String getCmdName(String internalKey) {
String full = cmdNames.get("{cmd_" + internalKey + "}");
return full != null ? full : "/ticket " + internalKey;
}
/**
* Übersetzt &-Farbcodes und Hex-Farbcodes (&#RRGGBB oder <#RRGGBB>) in §-Codes.
*/
public String color(String text) {
if (text == null || text.isEmpty()) return "";
// Regex für Hex Codes: &#RRGGBB oder <#RRGGBB>
Pattern hexPattern = Pattern.compile("&#([A-Fa-f0-9]{6})|<#([A-Fa-f0-9]{6})>");
Matcher matcher = hexPattern.matcher(text);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
String group = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
try {
// Fix: Explizite Nutzung von net.md_5.bungee.api.ChatColor für Hex-Support (Spigot 1.16+)
// Dies verhindert den "Symbol nicht gefunden"-Fehler beim Kompilieren mit der reinen Bukkit-API.
net.md_5.bungee.api.ChatColor hexColor = net.md_5.bungee.api.ChatColor.of("#" + group);
matcher.appendReplacement(buffer, hexColor.toString());
} catch (IllegalArgumentException e) {
// Falls der Farbcode ungültig ist, Tag entfernen
matcher.appendReplacement(buffer, "");
}
}
String parsed = matcher.appendTail(buffer).toString();
// Übersetzung der klassischen &-Farbcodes (Bukkit Standard)
return org.bukkit.ChatColor.translateAlternateColorCodes('&', parsed);
}
public String getPrefix() { return prefix; }
public String getActiveLang() { return activeLang; }
public String getFileLang() { return fileLang; }
/** Relädt die Sprachdatei. Muss NACH plugin.reloadConfig() aufgerufen werden. */
public void reload() { load(); }
// ── Intern ───────────────────────────────────────────────────────────────
private boolean needsPrefix(String key) {
for (String p : NO_PREFIX_PATHS) if (key.startsWith(p)) return false;
return true;
}
}

View File

@@ -1,6 +1,7 @@
package de.ticketsystem.manager; package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus; import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -14,103 +15,306 @@ public class TicketManager {
private final TicketPlugin plugin; private final TicketPlugin plugin;
// Cooldown Map: UUID → Zeit in Millis, wann das letzte Ticket erstellt wurde /** UUID → Zeitstempel der letzten Ticket-Erstellung */
private final Map<UUID, Long> cooldowns = new HashMap<>(); private final Map<UUID, Long> cooldowns = new HashMap<>();
public TicketManager(TicketPlugin plugin) { public TicketManager(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
} }
// ────────────────────────── Cooldown ────────────────────────────────── // ── Cooldown ──────────────────────────────────────────────────────────
public boolean hasCooldown(UUID uuid) { public boolean hasCooldown(UUID uuid) {
if (!cooldowns.containsKey(uuid)) return false; if (!cooldowns.containsKey(uuid)) return false;
long cooldownSeconds = plugin.getConfig().getLong("ticket-cooldown", 60) * 1000L; long cdMillis = plugin.getConfig().getLong("ticket-cooldown", 60) * 1000L;
return (System.currentTimeMillis() - cooldowns.get(uuid)) < cooldownSeconds; return (System.currentTimeMillis() - cooldowns.get(uuid)) < cdMillis;
} }
public long getRemainingCooldown(UUID uuid) { public long getRemainingCooldown(UUID uuid) {
long cooldownMillis = plugin.getConfig().getLong("ticket-cooldown", 60) * 1000L; long cdMillis = plugin.getConfig().getLong("ticket-cooldown", 60) * 1000L;
long elapsed = System.currentTimeMillis() - cooldowns.getOrDefault(uuid, 0L); long elapsed = System.currentTimeMillis() - cooldowns.getOrDefault(uuid, 0L);
return Math.max(0, (cooldownMillis - elapsed) / 1000); return Math.max(0, (cdMillis - elapsed) / 1000);
} }
public void setCooldown(UUID uuid) { public void setCooldown(UUID uuid) {
cooldowns.put(uuid, System.currentTimeMillis()); cooldowns.put(uuid, System.currentTimeMillis());
} }
// ─────────────────────────── Benachrichtigungen ──────────────────────── // ── Team-Benachrichtigungen ───────────────────────────────────────────
/** /**
* Benachrichtigt alle Online-Supporter und Admins über ein neues Ticket. * Benachrichtigt alle Supporter/Admins über ein neues Ticket.
* Bei BungeeCord wird die Nachricht an alle Server weitergeleitet.
*/ */
public void notifyTeam(Ticket ticket) { public void notifyTeam(Ticket ticket) {
String msg = plugin.formatMessage("messages.new-ticket-notify") String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
.replace("{player}", ticket.getCreatorName()) String message = ticket.getMessage() != null ? ticket.getMessage() : "";
.replace("{message}", ticket.getMessage())
.replace("{id}", String.valueOf(ticket.getId()));
for (Player p : Bukkit.getOnlinePlayers()) { String categoryInfo = "";
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { String priorityInfo = "";
p.sendMessage(msg);
// Klickbaren Hinweis senden (Bukkit Chat-Component) if (plugin.getConfig().getBoolean("categories-enabled", true)) {
p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen.")); ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
categoryInfo = plugin.lang().format("notify.team-category", "{category}", cat.getColored());
}
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
priorityInfo = plugin.lang().format("notify.team-priority", "{priority}", ticket.getPriority().getColored());
}
String serverInfo = "";
if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
serverInfo = plugin.lang().format("notify.team-server", "{server}", ticket.getServerName());
}
String msg = plugin.lang().format("ticket.new-notify",
"{player}", creatorName,
"{message}", message,
"{id}", String.valueOf(ticket.getId()))
+ categoryInfo + priorityInfo + serverInfo;
String guiHint = plugin.lang().get("notify.gui-hint");
if (plugin.isBungeeCordEnabled()) {
plugin.getBungeeMessenger().broadcastTeamNotification(msg + "\n" + guiHint);
} else {
for (Player p : Bukkit.getOnlinePlayers()) {
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(msg);
p.sendMessage(guiHint);
}
} }
} }
plugin.getDiscordWebhook().sendNewTicket(ticket);
} }
// ── Ersteller-Benachrichtigungen ──────────────────────────────────────
/** /**
* Benachrichtigt den Ersteller des Tickets, wenn es geclaimt wurde. * Benachrichtigt den Ersteller, dass sein Ticket angenommen wurde.
*/ */
public void notifyCreatorClaimed(Ticket ticket) { public void notifyCreatorClaimed(Ticket ticket) {
String claimerName = resolveClaimerName(ticket);
String msg = plugin.lang().format("ticket.claimed-notify",
"{id}", String.valueOf(ticket.getId()),
"{claimer}", claimerName);
deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
}
/**
* Prüft beim Server-Join ob Tickets während der Offline-Zeit
* geclaimt oder weitergeleitet wurden, und informiert den Spieler.
*/
public void notifyClaimedWhileOffline(Player player) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
var tickets = plugin.getDatabaseManager()
.getTicketsByStatus(TicketStatus.CLAIMED, TicketStatus.FORWARDED);
for (Ticket t : tickets) {
if (!t.getCreatorUUID().equals(player.getUniqueId())) continue;
if (t.isClaimerNotified()) continue;
final String name = t.getClaimerName() != null ? t.getClaimerName() : "Support";
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
if (t.getStatus() == TicketStatus.CLAIMED) {
player.sendMessage(plugin.lang().format("ticket.claimed-notify",
"{id}", String.valueOf(t.getId()), "{claimer}", name));
} else {
String forwardedTo = t.getForwardedToName() != null ? t.getForwardedToName() : "einen Supporter";
player.sendMessage(plugin.lang().format("ticket.forwarded-creator",
"{id}", String.valueOf(t.getId()), "{supporter}", forwardedTo));
}
// Flag NACH der Nachricht setzen sicher im Hauptthread
Bukkit.getScheduler().runTaskAsynchronously(plugin,
() -> plugin.getDatabaseManager().markClaimerNotified(t.getId()));
});
}
});
}
/**
* Benachrichtigt den Ersteller, dass sein Ticket weitergeleitet wurde.
*/
public void notifyCreatorForwarded(Ticket ticket) {
String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter";
String msg = plugin.lang().format("ticket.forwarded-creator",
"{id}", String.valueOf(ticket.getId()), "{supporter}", forwardedTo);
deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
}
/**
* Benachrichtigt den Supporter, an den ein Ticket weitergeleitet wurde.
*/
public void notifyForwardedTo(Ticket ticket, String fromName) {
if (ticket.getForwardedToUUID() == null) return;
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String msg = plugin.lang().format("ticket.forwarded-notify",
"{player}", creatorName, "{id}", String.valueOf(ticket.getId()));
deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg);
plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName);
}
/** Benachrichtigt den Ersteller über die Schließung seines Tickets. */
public void notifyCreatorClosed(Ticket ticket) {
notifyCreatorClosed(ticket, null);
}
public void notifyCreatorClosed(Ticket ticket, String closerName) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().markCloseNotified(ticket.getId()));
String comment = (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
? ticket.getCloseComment() : "";
String msg = plugin.lang().format("ticket.closed-notify", "{id}", String.valueOf(ticket.getId()));
// Bewertungsaufforderung aufbauen
String ratingMsg = null;
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
String id = String.valueOf(ticket.getId());
ratingMsg = plugin.lang().get("rating.prompt-header") + "\n"
+ plugin.lang().get("rating.prompt-title") + "\n"
+ plugin.lang().format("rating.prompt-good", "{id}", id) + "\n"
+ plugin.lang().format("rating.prompt-bad", "{id}", id) + "\n"
+ plugin.lang().get("rating.prompt-footer");
}
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) { if (creator != null && creator.isOnline()) {
String msg = plugin.formatMessage("messages.ticket-claimed-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{claimer}", ticket.getClaimerName());
creator.sendMessage(msg); creator.sendMessage(msg);
if (!comment.isEmpty())
creator.sendMessage(plugin.lang().format("ticket.close-comment-label", "{comment}", comment));
if (ratingMsg != null)
creator.sendMessage(ratingMsg);
} else if (plugin.isBungeeCordEnabled()) {
plugin.getBungeeMessenger().sendMessageToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
if (!comment.isEmpty())
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(),
plugin.lang().format("ticket.close-comment-label", "{comment}", comment));
if (ratingMsg != null)
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(), ratingMsg);
} else {
savePendingClosedNotification(ticket, comment);
} }
plugin.getDiscordWebhook().sendTicketClosed(ticket,
closerName != null ? closerName : "Unbekannt");
} }
/** /**
* Sendet dem weitergeleiteten Supporter eine Benachrichtigung. * @deprecated Bitte ticket.isCloseNotified() direkt verwenden.
*/ */
public void notifyForwardedTo(Ticket ticket) { @Deprecated
Player target = Bukkit.getPlayer(ticket.getForwardedToUUID()); public boolean wasClosedNotificationSent(int ticketId) {
if (target != null && target.isOnline()) { Ticket t = plugin.getDatabaseManager().getTicketById(ticketId);
String msg = plugin.formatMessage("messages.ticket-forwarded-notify") return t != null && t.isCloseNotified();
.replace("{player}", ticket.getCreatorName())
.replace("{id}", String.valueOf(ticket.getId()));
target.sendMessage(msg);
}
} }
// ─────────────────────────── Hilfsmethoden ───────────────────────────── // ── Ticket-Limit ──────────────────────────────────────────────────────
/**
* Prüft, ob ein Spieler zu viele offene Tickets hat.
*/
public boolean hasReachedTicketLimit(UUID uuid) { public boolean hasReachedTicketLimit(UUID uuid) {
int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2); int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2);
if (max <= 0) return false; if (max <= 0) return false;
return plugin.getDatabaseManager().countOpenTicketsByPlayer(uuid) >= max; return plugin.getDatabaseManager().countOpenTicketsByPlayer(uuid) >= max;
} }
// ── Hilfe-Nachricht ───────────────────────────────────────────────────
/**
* Sendet die Hilfe-Nachricht an den Spieler.
*
* Die Befehlsnamen in den lang.yml-Schlüsseln (z.B. help.create) enthalten
* {cmd_X}-Platzhalter. Der LanguageManager ersetzt diese automatisch
* anhand von language in config.yml:
*
* language: de → /ticket erstellen
* language: en → /ticket create
* language: both → /ticket create (erstellen)
*
* Hier muss kein manueller Sprachcode gelesen werden.
*/
public void sendHelpMessage(Player player) { public void sendHelpMessage(Player player) {
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(plugin.color("&6TicketSystem &7 Befehle")); player.sendMessage(plugin.lang().get("help.header"));
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(plugin.color("&e/ticket create <Text> &7 Neues Ticket erstellen")); player.sendMessage(plugin.lang().get("help.create"));
player.sendMessage(plugin.lang().get("help.list"));
player.sendMessage(plugin.lang().get("help.comment"));
if (plugin.getConfig().getBoolean("rating-enabled", true))
player.sendMessage(plugin.lang().get("help.rate"));
if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
player.sendMessage(plugin.color("&e/ticket list &7 Ticket-Übersicht (GUI)")); player.sendMessage(plugin.lang().get("help.claim"));
player.sendMessage(plugin.color("&e/ticket claim <ID> &7 Ticket annehmen")); player.sendMessage(plugin.lang().get("help.close"));
player.sendMessage(plugin.color("&e/ticket close <ID> &7 Ticket schließen"));
} }
if (player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.admin")) {
player.sendMessage(plugin.color("&e/ticket forward <ID> <Spieler> &7 Ticket weiterleiten")); player.sendMessage(plugin.lang().get("help.forward"));
player.sendMessage(plugin.color("&e/ticket reload &7 Konfiguration neu laden")); player.sendMessage(plugin.lang().get("help.blacklist"));
player.sendMessage(plugin.lang().get("help.reload"));
player.sendMessage(plugin.lang().get("help.stats"));
} }
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.lang().get("general.separator"));
if (player.hasPermission("ticket.admin") && plugin.isBungeeCordEnabled())
player.sendMessage(plugin.lang().format("help.bungee-status", "{server}", plugin.getServerName()));
} }
}
// ── Interne Hilfsmethoden ─────────────────────────────────────────────
/**
* Zustellung einer Nachricht an einen Spieler:
* 1. Lokal online → direkt senden
* 2. BungeeCord → via Plugin-Messaging
* 3. Offline → in Pending-DB speichern
*/
private void deliverToPlayer(UUID uuid, String name, String message) {
Player local = Bukkit.getPlayer(uuid);
if (local != null && local.isOnline()) {
local.sendMessage(message);
return;
}
if (plugin.isBungeeCordEnabled()) {
plugin.getBungeeMessenger().sendMessageToPlayer(uuid, name, message);
return;
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().addPendingNotification(uuid, message));
}
/**
* Speichert eine Schließ-Benachrichtigung für einen Offline-Spieler
* in der Pending-DB, damit sie beim nächsten Login zugestellt wird.
*/
private void savePendingClosedNotification(Ticket ticket, String comment) {
String commentPart = comment.isEmpty()
? ""
: plugin.lang().format("ticket.pending-closed-comment", "{comment}", comment);
String ratingPart = plugin.getConfig().getBoolean("rating-enabled", true)
? plugin.lang().format("ticket.pending-closed-rating", "{id}", String.valueOf(ticket.getId()))
: "";
String pendingMsg = plugin.lang().format("ticket.pending-closed",
"{id}", String.valueOf(ticket.getId()),
"{comment}", commentPart,
"{rating}", ratingPart);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), pendingMsg));
}
private String resolveClaimerName(Ticket ticket) {
if (ticket.getClaimerName() != null) return ticket.getClaimerName();
if (ticket.getClaimerUUID() != null) {
String name = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName();
if (name != null) return name;
}
return "Support";
}
}

View File

@@ -0,0 +1,53 @@
package de.ticketsystem.model;
import org.bukkit.Material;
/**
* Eine aus der config.yml geladene Ticket-Kategorie.
* Ersetzt das hardcodierte TicketCategory-Enum vollständig.
*
* Konfigurationsbeispiel (config.yml):
*
* categories:
* bug:
* name: "Bug"
* color: "&c"
* material: "REDSTONE"
* aliases:
* - "bug"
* - "fehler"
*/
public class ConfigCategory {
/** Interner Schlüssel aus der Config (z.B. "bug", "general") immer Kleinbuchstaben */
private final String key;
/** Anzeigename (z.B. "Bug", "Allgemein") */
private final String name;
/** Minecraft-Farbcode (z.B. "&c") */
private final String color;
/** GUI-Item-Material */
private final Material material;
public ConfigCategory(String key, String name, String color, Material material) {
this.key = key.toLowerCase();
this.name = name;
this.color = color;
this.material = material;
}
public String getKey() { return key; }
public String getName() { return name; }
public String getColor() { return color; }
public Material getMaterial() { return material; }
/** Gibt den farbigen Anzeigenamen zurück, z.B. "§cBug" */
public String getColored() {
return org.bukkit.ChatColor.translateAlternateColorCodes('&', color + name);
}
@Override
public String toString() { return key; }
}

View File

@@ -0,0 +1,29 @@
package de.ticketsystem.model;
/**
* Represents a single FAQ entry stored in faqs.yml.
*/
public class FaqEntry {
private int id;
private String question;
private String answer;
public FaqEntry(int id, String question, String answer) {
this.id = id;
this.question = question;
this.answer = answer;
}
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getQuestion() { return question; }
public void setQuestion(String q) { this.question = q; }
public String getAnswer() { return answer; }
public void setAnswer(String a) { this.answer = a; }
@Override
public String toString() {
return "FaqEntry{id=" + id + ", question='" + question + "'}";
}
}

View File

@@ -3,106 +3,214 @@ package de.ticketsystem.model;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.SerializableAs;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public class Ticket {
private int id; @SerializableAs("Ticket")
private UUID creatorUUID; public class Ticket implements ConfigurationSerializable {
private int id;
private UUID creatorUUID;
private String creatorName; private String creatorName;
private String message; private String message;
// Location-Felder (werden separat gespeichert)
private String worldName; private String worldName;
private double x, y, z; private double x, y, z;
private float yaw, pitch; private float yaw, pitch;
/**
* Name des Servers auf dem das Ticket erstellt wurde (BungeeCord-Netzwerk).
* Entspricht dem Wert aus config.yml → server-name.
* Standardwert: "unknown"
*/
private String serverName = "unknown";
private TicketStatus status; private TicketStatus status;
private UUID claimerUUID; private UUID claimerUUID;
private String claimerName; private String claimerName;
private UUID forwardedToUUID; private UUID forwardedToUUID;
private String forwardedToName; private String forwardedToName;
private Timestamp createdAt; private Timestamp createdAt;
private Timestamp claimedAt; private Timestamp claimedAt;
private Timestamp closedAt; private Timestamp closedAt;
private String closeComment;
private boolean playerDeleted = false;
/** Kategorie-Key aus config.yml, z.B. "bug", "general" */
private String categoryKey = "general";
private TicketPriority priority = TicketPriority.NORMAL;
/** null = nicht bewertet | "THUMBS_UP" | "THUMBS_DOWN" */
private String playerRating = null;
private boolean claimerNotified = false;
/**
* Gibt an ob der Ersteller bereits über die Schließung informiert wurde.
* Wird in der DB gespeichert damit Server-Wechsel keine Duplikate erzeugen.
*/
private boolean closeNotified = false;
public Ticket() {} public Ticket() {}
public Ticket(UUID creatorUUID, String creatorName, String message, Location location) { public Ticket(UUID creatorUUID, String creatorName, String message, Location location) {
this.creatorUUID = creatorUUID; this.creatorUUID = creatorUUID;
this.creatorName = creatorName; this.creatorName = creatorName;
this.message = message; this.message = message;
this.worldName = location.getWorld().getName(); this.worldName = location.getWorld().getName();
this.x = location.getX(); this.x = location.getX(); this.y = location.getY(); this.z = location.getZ();
this.y = location.getY(); this.yaw = location.getYaw(); this.pitch = location.getPitch();
this.z = location.getZ(); this.status = TicketStatus.OPEN;
this.yaw = location.getYaw();
this.pitch = location.getPitch();
this.status = TicketStatus.OPEN;
this.createdAt = new Timestamp(System.currentTimeMillis()); this.createdAt = new Timestamp(System.currentTimeMillis());
} }
public Location getLocation() { public Ticket(Map<String, Object> map) {
World world = Bukkit.getWorld(worldName); this.id = (int) map.get("id");
if (world == null) return null; Object cObj = map.get("creatorUUID");
return new Location(world, x, y, z, yaw, pitch); this.creatorUUID = cObj instanceof UUID ? (UUID) cObj : UUID.fromString((String) cObj);
this.creatorName = (String) map.get("creatorName");
this.message = (String) map.get("message");
this.worldName = (String) map.get("world");
this.x = toDouble(map.get("x")); this.y = toDouble(map.get("y")); this.z = toDouble(map.get("z"));
this.yaw = toFloat(map.get("yaw")); this.pitch = toFloat(map.get("pitch"));
this.status = TicketStatus.valueOf((String) map.get("status"));
if (map.get("createdAt") != null) this.createdAt = new Timestamp(toLong(map.get("createdAt")));
if (map.get("claimedAt") != null) this.claimedAt = new Timestamp(toLong(map.get("claimedAt")));
if (map.get("closedAt") != null) this.closedAt = new Timestamp(toLong(map.get("closedAt")));
this.closeComment = (String) map.get("closeComment");
if (map.containsKey("claimerUUID") && map.get("claimerUUID") != null) {
Object o = map.get("claimerUUID");
this.claimerUUID = o instanceof UUID ? (UUID) o : UUID.fromString((String) o);
this.claimerName = (String) map.get("claimerName");
}
if (map.containsKey("forwardedToUUID") && map.get("forwardedToUUID") != null) {
Object o = map.get("forwardedToUUID");
this.forwardedToUUID = o instanceof UUID ? (UUID) o : UUID.fromString((String) o);
this.forwardedToName = (String) map.get("forwardedToName");
}
if (map.containsKey("playerDeleted")) this.playerDeleted = (boolean) map.get("playerDeleted");
if (map.containsKey("category")) this.categoryKey = (String) map.get("category");
if (map.containsKey("priority")) this.priority = TicketPriority.fromString((String) map.get("priority"));
if (map.containsKey("playerRating")) this.playerRating = (String) map.get("playerRating");
if (map.containsKey("claimerNotified")) this.claimerNotified = (boolean) map.get("claimerNotified");
// BungeeCord: Server-Name laden (Fallback: "unknown")
if (map.containsKey("serverName")) this.serverName = (String) map.get("serverName");
if (map.containsKey("closeNotified")) this.closeNotified = (boolean) map.get("closeNotified");
} }
// ─────────────────────────── Getter & Setter ──────────────────────────── @Override
public Map<String, Object> serialize() {
Map<String, Object> map = new HashMap<>();
map.put("id", id);
// Null-Schutz: SnakeYAML wirft NPE wenn Key oder Value null sind
map.put("creatorUUID", creatorUUID != null ? creatorUUID.toString() : "");
map.put("creatorName", creatorName != null ? creatorName : "");
map.put("message", message != null ? message : "");
map.put("world", worldName != null ? worldName : "world");
map.put("x", x); map.put("y", y); map.put("z", z);
map.put("yaw", yaw); map.put("pitch", pitch);
map.put("status", status != null ? status.name() : TicketStatus.OPEN.name());
if (createdAt != null) map.put("createdAt", createdAt.getTime());
if (claimedAt != null) map.put("claimedAt", claimedAt.getTime());
if (closedAt != null) map.put("closedAt", closedAt.getTime());
if (closeComment != null) map.put("closeComment", closeComment);
if (claimerUUID != null) {
map.put("claimerUUID", claimerUUID.toString());
map.put("claimerName", claimerName != null ? claimerName : "");
}
if (forwardedToUUID != null) {
map.put("forwardedToUUID", forwardedToUUID.toString());
map.put("forwardedToName", forwardedToName != null ? forwardedToName : "");
}
map.put("playerDeleted", playerDeleted);
map.put("category", categoryKey != null ? categoryKey : "general");
map.put("priority", priority != null ? priority.name() : TicketPriority.NORMAL.name());
if (playerRating != null) map.put("playerRating", playerRating);
map.put("claimerNotified", claimerNotified);
// BungeeCord: Server-Name speichern
map.put("serverName", serverName != null ? serverName : "unknown");
map.put("closeNotified", closeNotified);
return map;
}
public int getId() { return id; } public static void register() { ConfigurationSerialization.registerClass(Ticket.class, "Ticket"); }
public void setId(int id) { this.id = id; }
public UUID getCreatorUUID() { return creatorUUID; } public Location getLocation() {
public void setCreatorUUID(UUID creatorUUID) { this.creatorUUID = creatorUUID; } World world = Bukkit.getWorld(worldName);
return world == null ? null : new Location(world, x, y, z, yaw, pitch);
}
public String getCreatorName() { return creatorName; } private static double toDouble(Object o) { return o instanceof Double d ? d : ((Number) o).doubleValue(); }
public void setCreatorName(String creatorName) { this.creatorName = creatorName; } private static float toFloat(Object o) { return o instanceof Float f ? f : ((Number) o).floatValue(); }
private static long toLong(Object o) { return ((Number) o).longValue(); }
public String getMessage() { return message; } // ─────────────────────────── Getter & Setter ───────────────────────────
public void setMessage(String message) { this.message = message; }
public String getWorldName() { return worldName; } public int getId() { return id; }
public void setWorldName(String worldName) { this.worldName = worldName; } public void setId(int id) { this.id = id; }
public UUID getCreatorUUID() { return creatorUUID; }
public void setCreatorUUID(UUID v) { this.creatorUUID = v; }
public String getCreatorName() { return creatorName; }
public void setCreatorName(String v) { this.creatorName = v; }
public String getMessage() { return message; }
public void setMessage(String v) { this.message = v; }
public String getWorldName() { return worldName; }
public void setWorldName(String v) { this.worldName = v; }
public double getX() { return x; }
public void setX(double v) { this.x = v; }
public double getY() { return y; }
public void setY(double v) { this.y = v; }
public double getZ() { return z; }
public void setZ(double v) { this.z = v; }
public float getYaw() { return yaw; }
public void setYaw(float v) { this.yaw = v; }
public float getPitch() { return pitch; }
public void setPitch(float v) { this.pitch = v; }
public TicketStatus getStatus() { return status; }
public void setStatus(TicketStatus v) { this.status = v; }
public UUID getClaimerUUID() { return claimerUUID; }
public void setClaimerUUID(UUID v) { this.claimerUUID = v; }
public String getClaimerName() { return claimerName; }
public void setClaimerName(String v) { this.claimerName = v; }
public UUID getForwardedToUUID() { return forwardedToUUID; }
public void setForwardedToUUID(UUID v) { this.forwardedToUUID = v; }
public String getForwardedToName() { return forwardedToName; }
public void setForwardedToName(String v) { this.forwardedToName = v; }
public Timestamp getCreatedAt() { return createdAt; }
public void setCreatedAt(Timestamp v) { this.createdAt = v; }
public Timestamp getClaimedAt() { return claimedAt; }
public void setClaimedAt(Timestamp v) { this.claimedAt = v; }
public Timestamp getClosedAt() { return closedAt; }
public void setClosedAt(Timestamp v) { this.closedAt = v; }
public String getCloseComment() { return closeComment; }
public void setCloseComment(String v) { this.closeComment = v; }
public boolean isPlayerDeleted() { return playerDeleted; }
public void setPlayerDeleted(boolean v) { this.playerDeleted = v; }
public String getCategoryKey() { return categoryKey; }
public void setCategoryKey(String v) { this.categoryKey = v != null ? v.toLowerCase() : "general"; }
public TicketPriority getPriority() { return priority; }
public void setPriority(TicketPriority v) { this.priority = v; }
public String getPlayerRating() { return playerRating; }
public void setPlayerRating(String v) { this.playerRating = v; }
public boolean hasRating() { return playerRating != null; }
public boolean isClaimerNotified() { return claimerNotified; }
public void setClaimerNotified(boolean v) { this.claimerNotified = v; }
public double getX() { return x; } /** BungeeCord: Gibt den Server-Namen zurück, auf dem das Ticket erstellt wurde. */
public void setX(double x) { this.x = x; } public String getServerName() { return serverName != null ? serverName : "unknown"; }
/** BungeeCord: Setzt den Server-Namen (aus config.yml → server-name). */
public void setServerName(String v) { this.serverName = v != null ? v : "unknown"; }
public double getY() { return y; } /** Gibt an ob der Ersteller bereits über die Schließung informiert wurde (DB-persistent). */
public void setY(double y) { this.y = y; } public boolean isCloseNotified() { return closeNotified; }
/** Setzt den close_notified-Flag (wird in DB gespeichert). */
public double getZ() { return z; } public void setCloseNotified(boolean v) { this.closeNotified = v; }
public void setZ(double z) { this.z = z; } }
public float getYaw() { return yaw; }
public void setYaw(float yaw) { this.yaw = yaw; }
public float getPitch() { return pitch; }
public void setPitch(float pitch) { this.pitch = pitch; }
public TicketStatus getStatus() { return status; }
public void setStatus(TicketStatus status) { this.status = status; }
public UUID getClaimerUUID() { return claimerUUID; }
public void setClaimerUUID(UUID claimerUUID) { this.claimerUUID = claimerUUID; }
public String getClaimerName() { return claimerName; }
public void setClaimerName(String claimerName) { this.claimerName = claimerName; }
public UUID getForwardedToUUID() { return forwardedToUUID; }
public void setForwardedToUUID(UUID forwardedToUUID) { this.forwardedToUUID = forwardedToUUID; }
public String getForwardedToName() { return forwardedToName; }
public void setForwardedToName(String forwardedToName) { this.forwardedToName = forwardedToName; }
public Timestamp getCreatedAt() { return createdAt; }
public void setCreatedAt(Timestamp createdAt) { this.createdAt = createdAt; }
public Timestamp getClaimedAt() { return claimedAt; }
public void setClaimedAt(Timestamp claimedAt) { this.claimedAt = claimedAt; }
public Timestamp getClosedAt() { return closedAt; }
public void setClosedAt(Timestamp closedAt) { this.closedAt = closedAt; }
}

View File

@@ -0,0 +1,33 @@
package de.ticketsystem.model;
import org.bukkit.Material;
public enum TicketCategory {
GENERAL ("Allgemein", "§7", Material.PAPER),
BUG ("Bug", "§c", Material.REDSTONE),
QUESTION ("Frage", "§e", Material.BOOK),
COMPLAINT ("Beschwerde", "§6", Material.WRITABLE_BOOK),
OTHER ("Sonstiges", "§8", Material.FEATHER);
private final String displayName;
private final String color;
private final Material guiMaterial;
TicketCategory(String displayName, String color, Material guiMaterial) {
this.displayName = displayName;
this.color = color;
this.guiMaterial = guiMaterial;
}
public String getDisplayName() { return displayName; }
public String getColor() { return color; }
public String getColored() { return color + displayName; }
public Material getGuiMaterial() { return guiMaterial; }
/** Safely parse from stored string, fall back to GENERAL. */
public static TicketCategory fromString(String s) {
if (s == null) return GENERAL;
try { return valueOf(s.toUpperCase()); }
catch (IllegalArgumentException e) { return GENERAL; }
}
}

View File

@@ -0,0 +1,47 @@
package de.ticketsystem.model;
import java.sql.Timestamp;
import java.util.UUID;
/**
* Represents a player comment/reply on a ticket.
*/
public class TicketComment {
private int id;
private int ticketId;
private UUID authorUUID;
private String authorName;
private String message;
private Timestamp createdAt;
public TicketComment() {}
public TicketComment(int ticketId, UUID authorUUID, String authorName, String message) {
this.ticketId = ticketId;
this.authorUUID = authorUUID;
this.authorName = authorName;
this.message = message;
this.createdAt = new Timestamp(System.currentTimeMillis());
}
// ─────────────── Getters / Setters ────────────────────────────────────
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public int getTicketId() { return ticketId; }
public void setTicketId(int ticketId) { this.ticketId = ticketId; }
public UUID getAuthorUUID() { return authorUUID; }
public void setAuthorUUID(UUID authorUUID) { this.authorUUID = authorUUID; }
public String getAuthorName() { return authorName; }
public void setAuthorName(String authorName){ this.authorName = authorName; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public Timestamp getCreatedAt() { return createdAt; }
public void setCreatedAt(Timestamp ts) { this.createdAt = ts; }
}

View File

@@ -0,0 +1,31 @@
package de.ticketsystem.model;
import org.bukkit.Material;
public enum TicketPriority {
LOW ("Niedrig", "§a", Material.GREEN_WOOL),
NORMAL ("Normal", "§e", Material.YELLOW_WOOL),
HIGH ("Hoch", "§6", Material.ORANGE_WOOL),
URGENT ("Dringend","§c", Material.RED_WOOL);
private final String displayName;
private final String color;
private final Material guiMaterial;
TicketPriority(String displayName, String color, Material guiMaterial) {
this.displayName = displayName;
this.color = color;
this.guiMaterial = guiMaterial;
}
public String getDisplayName() { return displayName; }
public String getColor() { return color; }
public String getColored() { return color + displayName; }
public Material getGuiMaterial() { return guiMaterial; }
public static TicketPriority fromString(String s) {
if (s == null) return NORMAL;
try { return valueOf(s.toUpperCase()); }
catch (IllegalArgumentException e) { return NORMAL; }
}
}

View File

@@ -7,16 +7,52 @@
# |___/ # |___/
# #
# TicketSystem - Ein einfaches und effizientes Ticketsystem für Minecraft-Server # TicketSystem - Ein einfaches und effizientes Ticketsystem für Minecraft-Server
# Entwickelt von M_Viper # Entwickelt von M_Viper
#
# HINWEIS: Alle Texte und Nachrichten befinden sich in lang_de.yml / lang_en.yml!
# ============================================================ # ============================================================
# --- GRUNDLEGEND --- # --- GRUNDLEGEND ---
# Version der Konfigurationsdatei. Nicht ändern! # Version der Konfigurationsdatei. Nicht ändern!
version: "2.0" version: "2.2"
# Debug-Modus (true = Logs in der Konsole) # ----------------------------------------------------
# SPRACHE / LANGUAGE
# ----------------------------------------------------
# Steuert sowohl die Texte als auch die Befehlsnamen.
#
# de → deutsche Texte + /ticket erstellen, /ticket schließen ...
# en → englische Texte + /ticket create, /ticket close ...
# both → deutsche Texte + /ticket create (erstellen) ...
#
# Die passende Datei (lang_de.yml / lang_en.yml) wird automatisch
# im Plugin-Ordner erstellt und kann frei bearbeitet werden.
# ----------------------------------------------------
language: de
# Debug-Modus (true = zusätzliche Logs in der Konsole)
debug: false debug: false
# ----------------------------------------------------
# BUNGEECORD (Cross-Server-Unterstützung)
# ----------------------------------------------------
# VORAUSSETZUNGEN:
# 1. In spigot.yml auf JEDEM Server: bungeecord: true
# 2. MySQL muss aktiviert sein (use-mysql: true)
# 3. Plugin auf JEDEM Spigot-Server installieren
# 4. Alle Server müssen dieselbe MySQL-Datenbank verwenden
#
# false = Normaler Single-Server-Modus (Standard)
# true = Cross-Server Benachrichtigungen aktiv
# ----------------------------------------------------
bungeecord: false
bungee-teleport-enabled: true
# Name dieses Servers im BungeeCord-Netzwerk.
# Wird in Tickets, GUI und Discord-Embeds angezeigt.
# Auf jedem Server ANDERS einstellen! (z.B. "survival", "creative", "skyblock")
server-name: "survival"
# ---------------------------------------------------- # ----------------------------------------------------
# SPEICHERPFAD & ARCHIV # SPEICHERPFAD & ARCHIV
# ---------------------------------------------------- # ----------------------------------------------------
@@ -42,11 +78,6 @@ mysql:
pool-size: 10 # HikariCP Poolgröße pool-size: 10 # HikariCP Poolgröße
connection-timeout: 30000 # Timeout in ms connection-timeout: 30000 # Timeout in ms
# ----------------------------------------------------
# PLUGIN-PRÄFIX (Chat)
# ----------------------------------------------------
prefix: "&8[&6Ticket&8] &r" # Präfix für Chat-Ausgaben
# ---------------------------------------------------- # ----------------------------------------------------
# LIMITS & OPTIONEN # LIMITS & OPTIONEN
# ---------------------------------------------------- # ----------------------------------------------------
@@ -60,35 +91,199 @@ max-open-tickets-per-player: 2 # Maximale offene Tickets pro Spieler (0 = unbeg
auto-archive-interval-hours: 24 # Intervall in Stunden (0 = aus) auto-archive-interval-hours: 24 # Intervall in Stunden (0 = aus)
# ---------------------------------------------------- # ----------------------------------------------------
# SYSTEM-NACHRICHTEN (mit &-Farbcodes) # PERFORMANCE
# ---------------------------------------------------- # ----------------------------------------------------
messages: cache-ttl-seconds: 60 # Wie lange Tickets im In-Memory-Cache gehalten werden
# --- SYSTEM ---
export-success: "&aExport erfolgreich: &e{count} &aTickets nach &e{file} &aexportiert."
export-fail: "&cExport fehlgeschlagen oder keine Tickets gefunden."
import-success: "&aImport erfolgreich: &e{count} &aTickets importiert."
import-fail: "&cImport fehlgeschlagen oder keine Tickets gefunden."
migration-success: "&aMigration abgeschlossen: &e{count} &aTickets migriert."
migration-fail: "&cKeine Tickets migriert oder Fehler aufgetreten."
archive-success: "&aArchivierung abgeschlossen: &e{count} &aTickets archiviert."
archive-fail: "&cKeine geschlossenen Tickets zum Archivieren gefunden."
file-not-found: "&cDatei nicht gefunden: &e{file}"
unknown-mode: "&cUnbekannter Modus! Benutze: tomysql oder tofile"
validation-warning: "&cEs wurden &e{count} &cungültige Tickets beim Laden gefunden."
# --- TICKET-AKTIONEN --- # ----------------------------------------------------
ticket-created: "&aTicket &e#{id} &awurde erfolgreich erstellt!" # OPTIONALE FEATURES
ticket-claimed: "&aDu hast Ticket &e#{id} &avon &e{player} &ageclaimt." # ----------------------------------------------------
ticket-claimed-notify: "&aDein Ticket &e#{id} &awurde von &e{claimer} &aangenommen."
ticket-closed: "&aTicket &e#{id} &awurde geschlossen."
ticket-forwarded: "&aTicket &e#{id} &awurde an &e{player} &aweitergeleitet."
ticket-forwarded-notify: "&eDu hast ein Ticket von &6{player} &eweitergeleitet bekommen."
# --- FEHLER & HINWEISE --- # Kategorie-System (true = aktiviert)
no-permission: "&cDu hast keine Berechtigung!" # Spieler können beim Erstellen eine Kategorie wählen: /ticket create [kategorie] [priorität] <text>
no-open-tickets: "&aAktuell gibt es keine offenen Tickets." categories-enabled: true
join-open-tickets: "&eEs gibt noch &6{count} &eoffene Ticket(s)!"
new-ticket-notify: "&e{player} &ahat ein neues Ticket erstellt: &7{message}" # Prioritäten-System (true = aktiviert)
already-claimed: "&cDieses Ticket wurde bereits geclaimt!" # Spieler können beim Erstellen eine Priorität wählen: /ticket create [kategorie] [priorität] <text>
ticket-not-found: "&cTicket nicht gefunden!" # Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority <id> <low|normal|high|urgent>
cooldown: "&cBitte warte &e{seconds} Sekunden &cbevor du ein neues Ticket erstellst." priorities-enabled: true
# Bewertungs-System (true = aktiviert)
# Spieler können nach dem Schließen den Support bewerten: /ticket rate <id> good|bad
# Ergebnisse sind in /ticket stats sichtbar
rating-enabled: true
# ----------------------------------------------------
# KATEGORIEN (nur aktiv wenn categories-enabled: true)
# ----------------------------------------------------
# Jede Kategorie hat:
# name: Anzeigename im Chat und in der GUI
# color: Farbcode mit & (Minecraft Farbcodes)
# material: Minecraft-Material für das GUI-Item (Großbuchstaben, z.B. PAPER, REDSTONE, BOOK)
# aliases: Alternative Eingaben beim /ticket create Befehl (Kleinbuchstaben!)
#
# Das erste eingetragene Item ist die Standard-Kategorie für Tickets ohne Angabe.
# Du kannst beliebig viele Kategorien hinzufügen oder entfernen.
# ----------------------------------------------------
categories:
general:
name: "Allgemein"
color: "&7"
material: "PAPER"
aliases:
- "allgemein"
- "general"
- "default"
bug:
name: "Bug"
color: "&c"
material: "REDSTONE"
aliases:
- "bug"
- "fehler"
- "error"
question:
name: "Frage"
color: "&e"
material: "BOOK"
aliases:
- "frage"
- "question"
- "help"
- "hilfe"
complaint:
name: "Beschwerde"
color: "&6"
material: "WRITABLE_BOOK"
aliases:
- "beschwerde"
- "complaint"
- "report"
- "melden"
other:
name: "Sonstiges"
color: "&8"
material: "FEATHER"
aliases:
- "sonstiges"
- "other"
- "misc"
# ----------------------------------------------------
# DISCORD WEBHOOK (Optional)
# ----------------------------------------------------
discord:
# Auf true setzen um Discord-Benachrichtigungen zu aktivieren
enabled: false
# Webhook-URL aus Discord (Kanaleinstellungen → Integrationen → Webhook erstellen)
webhook-url: ""
# Rollen-Ping: Discord-Rollen-ID (Rechtsklick auf Rolle → ID kopieren)
# Leer lassen ("") = kein Ping
role-ping-id: ""
messages:
# ── Neues Ticket ────────────────────────────────────────────────────────
new-ticket:
title: "🎫 Neues Ticket erstellt"
color: "3066993" # Grün
footer: "TicketSystem"
show-position: true # Welt & Koordinaten im Embed anzeigen
show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität im Embed anzeigen
show-server: true # BungeeCord: Server-Name im Embed anzeigen
role-ping: false # Rollen-Ping bei neuem Ticket senden
# ── Ticket geschlossen ──────────────────────────────────────────────────
ticket-closed:
enabled: false # Webhook-Nachricht beim Schließen senden
title: "🔒 Ticket geschlossen"
color: "15158332" # Rot
footer: "TicketSystem"
show-category: true
show-priority: true
show-server: true
role-ping: false
# ── Ticket weitergeleitet ───────────────────────────────────────────────
ticket-forwarded:
enabled: false # Webhook-Nachricht beim Weiterleiten senden
title: "🔀 Ticket weitergeleitet"
color: "15105570" # Orange
footer: "TicketSystem"
show-category: true
show-priority: true
show-server: true
role-ping: false
# ============================================================
# GUI KONFIGURATION (Layouts, Slots, Items)
# ============================================================
# Hier kannst du das Aussehen und die Anordnung der GUIs anpassen.
# WICHTIG: gui-settings muss ganz links stehen (keine Raute davor!).
gui-settings:
# --- FAQ SYSTEM SETTINGS ---
faq:
# Größe des Inventars (4-6 Reihen, Minimum 4)
rows: 6
# Content-Slots für FAQ-Items.
# Wenn leer: Automatisches Schachbrett-Muster (Items und leere Slots wechselnd,
# letzte Reihe = Navigation/Footer).
# Wenn gefüllt: Nur diese Slots werden für FAQs genutzt (Liste von Zahlen).
# Beispiel: content-slots: [1, 3, 5, 7, 10, 12, 14, 16] -> Nur ungerade Slots
content-slots: []
# Kopfeinstellungen
head-item:
# Material des FAQ-Items (z.B. PLAYER_HEAD, BOOK, PAPER)
material: PLAYER_HEAD
# Optional: Texture-URL für den Kopf (wenn Material PLAYER_HEAD)
texture: "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e"
# Navigations-Slots (Prev, Next, Add, Page)
nav:
prev: 45
next: 53
add: 50
page: 49
# --- TICKET GUI SETTINGS ---
ticket:
# Spieler GUI
player:
rows: 6
nav:
prev: 45
next: 53
page: 49
# Admin / Team GUI
admin:
nav:
prev: 45
next: 53
page: 48
archive: 49
filter: 47
# Archiv GUI
archive:
nav:
prev: 45
next: 53
back: 49
# --- GUI ITEM MATERIALS (Optional) ---
# Hier kannst du das Material der Navigations-Buttons ändern.
# Wenn nicht gesetzt, werden Standard-Werte genutzt.
items:
nav-prev: ARROW
nav-next: ARROW
nav-page: PAPER
nav-archive: CHEST
nav-back: ARROW
nav-filter: HOPPER
nav-add: LIME_WOOL

View File

@@ -0,0 +1,460 @@
# ============================================================
# TicketSystem Sprachdatei Deutsch (de)
#
# Alle Texte des Plugins können hier angepasst werden.
# Farbcodes: & (z. B. &a = Grün, &c = Rot, &e = Gelb, &7 = Grau)
# HEX-Codes: &#RRGGBB (z. B. &#FFD700 = Gold)
# Platzhalter werden in geschweiften Klammern angegeben: {id}, {player}, ...
#
# Sprache in config.yml wechseln: language: de | en | both
#
# {cmd_X} wird automatisch je nach language ersetzt, z.B.:
# language: de → /ticket erstellen
# language: en → /ticket create
# language: both → /ticket create (erstellen)
# ============================================================
prefix: "&#FFAA00[&fTicket&#FFAA00] &r"
# ============================================================
# ALLGEMEINE FEHLER & HINWEISE
# ============================================================
general:
no-permission: "&cDu hast keine Berechtigung!"
console-only: "&cDieser Befehl kann nur von Spielern ausgeführt werden."
invalid-id: "&cUngültige ID!"
invalid-player-id: "&cUngültige Ticket-ID: &e{id}"
player-not-found: "&cSpieler nicht gefunden!"
ticket-not-found: "&cTicket nicht gefunden!"
already-claimed: "&cDieses Ticket wurde bereits angenommen!"
no-open-tickets: "&aAktuell gibt es keine offenen Tickets."
cooldown: "&cBitte warte &e{seconds} Sekunden &cbevor du ein neues Ticket erstellst."
separator: "&#555555&m "
# ============================================================
# SYSTEM (Export, Import, Migration, Archiv, Validierung)
# ============================================================
system:
export-success: "&aExport erfolgreich: &e{count} &aTickets nach &e{file} &aexportiert."
export-fail: "&cExport fehlgeschlagen oder keine Tickets gefunden."
import-success: "&aImport erfolgreich: &e{count} &aTickets importiert."
import-fail: "&cImport fehlgeschlagen oder keine Tickets gefunden."
migration-success: "&aMigration abgeschlossen: &e{count} &aTickets migriert."
migration-fail: "&cKeine Tickets migriert oder Fehler aufgetreten."
archive-success: "&aArchivierung abgeschlossen: &e{count} &aTickets archiviert."
archive-fail: "&cKeine geschlossenen Tickets zum Archivieren gefunden."
file-not-found: "&cDatei nicht gefunden: &e{file}"
unknown-mode: "&cUnbekannter Modus! Benutze: tomysql oder tofile"
validation-warning: "&cEs wurden &e{count} &cungültige Tickets beim Laden gefunden."
db-create-error: "&cFehler beim Erstellen des Tickets!"
# ============================================================
# TICKET-AKTIONEN
# ============================================================
ticket:
created: "&aTicket &e#{id} &awurde erfolgreich erstellt!"
created-category: "&aTicket &e#{id} &aerstellt! &7Kategorie: {category}"
claimed: "&aDu hast Ticket &e#{id} &avon &e{player} &aangenommen."
claimed-notify: "&aDein Ticket &e#{id} &awurde von &e{claimer} &aangenommen."
closed: "&aTicket &e#{id} &awurde geschlossen."
closed-notify: "&aDein Ticket &e#{id} &awurde geschlossen."
forwarded: "&aTicket &e#{id} &awurde an &e{player} &aweitergeleitet."
forwarded-notify: "&eDu hast ein Ticket von &6{player} &eweitergeleitet bekommen. &7(ID: {id})"
forwarded-creator: "&eDein Ticket &6#{id} &ewurde an &b{supporter} &eweitergeleitet."
new-notify: "&e{player} &ahat ein neues Ticket erstellt: &7{message} &7(ID: &e{id}&7)"
close-comment-label: "&7Kommentar des Supports: &f{comment}"
close-comment-short: "&7Kommentar: &f{comment}"
pending-closed: "&e[Ticket #{id}] &7Dein Ticket wurde geschlossen.{comment}{rating}"
pending-closed-comment: " &7Kommentar: &f{comment}"
pending-closed-rating: " &7Bewertung: &e{cmd_rate} {id} good/bad"
# ============================================================
# BENACHRICHTIGUNGEN (Team / GUI-Hinweis)
# ============================================================
notify:
gui-hint: "&7» Klicke &e{cmd_list} &7um die Übersicht zu öffnen."
team-category: " §7[§r{category}§7]"
team-priority: " §7Priorität: §r{priority}"
team-server: " §7Server: §b{server}"
# ============================================================
# TICKET ERSTELLEN
# ============================================================
create:
usage: "&cBenutzung: {cmd_create} [Kategorie] [Priorität] <Beschreibung>"
categories-hint: "&7Kategorien: &ebug&7, &efrage&7, &ebeschwerde&7, &esonstiges&7, &eallgemein"
priorities-hint: "&7Prioritäten: &alow&7, &enormal&7, &6high&7, &curgent"
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."
too-long: "&cDeine Beschreibung ist zu lang! Maximal {max} Zeichen."
blacklist-blocked: "&cDu wurdest vom Ticket-System gesperrt und kannst keine Tickets erstellen."
category-invalid: "&cUnbekannte Kategorie: &e{input}&c. Verfügbare Kategorien: &e{categories}"
# ============================================================
# CLAIM / CLOSE / FORWARD
# ============================================================
claim:
usage: "&cBenutzung: {cmd_claim} <ID>"
close:
usage: "&cBenutzung: {cmd_close} <ID> [Kommentar]"
forward:
usage: "&cBenutzung: {cmd_forward} <ID> <Spieler>"
bungee-offline: "&7[BungeeCord] Spieler &e{player} &7ist auf diesem Server nicht online."
local-not-found: "&cSpieler nicht gefunden!"
# ============================================================
# KOMMENTARE
# ============================================================
comment:
saved: "&aDein Kommentar zu Ticket &e#{id} &awurde gespeichert."
usage: "&cBenutzung: {cmd_comment} <ID> <Nachricht>"
too-long: "&cNachricht zu lang! Maximal 500 Zeichen."
no-permission: "&cDu kannst nur deine eigenen Tickets kommentieren."
error: "&cFehler beim Speichern des Kommentars."
notify-online: "&e[Ticket #{id}] &f{author} &7hat kommentiert: &f{message}"
notify-offline: "&e[Ticket #{id}] &f{author} &7hat kommentiert (während du offline warst): &f{message}"
claimer-offline: "&e[Ticket #{id}] &f{author} &7hat auf dein bearbeitetes Ticket kommentiert (offline): &f{message}"
# ============================================================
# BEWERTUNGEN
# ============================================================
rating:
saved-good: "&aDanke für deine Bewertung! &a👍 Positiv"
saved-bad: "&aDanke für deine Bewertung! &c👎 Negativ"
already-rated: "&cDu hast dieses Ticket bereits bewertet."
not-yours: "&cDu kannst nur deine eigenen Tickets bewerten."
disabled: "&cBewertungen sind aktuell deaktiviert."
not-closeable: "&cBewertung konnte nicht gespeichert werden. Ist das Ticket noch offen?"
usage: "&cBenutzung: {cmd_rate} <ID> <good|bad>"
invalid: "&cUngültige Bewertung! Benutze &egood &coder &ebad&c."
prompt-header: "&#555555&m "
prompt-title: "&6Wie zufrieden bist du mit dem Support?"
prompt-good: "&a{cmd_rate} {id} good &7 👍 Gut"
prompt-bad: "&c{cmd_rate} {id} bad &7 👎 Schlecht"
prompt-footer: "&#555555&m "
# ============================================================
# PRIORITÄT SETZEN
# ============================================================
setpriority:
usage: "&cBenutzung: {cmd_setpriority} <ID> <low|normal|high|urgent>"
disabled: "&cDas Prioritäten-System ist deaktiviert."
invalid: "&cUngültige Priorität! Gültig: &alow&7, &enormal&7, &6high&7, &curgent"
success: "&aPriorität von Ticket &e#{id} &awurde auf {priority} &agesetzt."
not-found: "&cTicket &e#{id} &cwurde nicht gefunden."
# ============================================================
# BLACKLIST
# ============================================================
blacklist:
added: "&a{player} &awurde zur Ticket-Blacklist hinzugefügt. &7Grund: &e{reason}"
removed: "&a{player} &awurde von der Blacklist entfernt."
already: "&cSpieler ist bereits auf der Blacklist."
not-found: "&cSpieler war nicht auf der Blacklist."
usage: "&cBenutzung: {cmd_blacklist} <add|remove|list> [Spieler] [Grund]"
usage-add: "&cBenutzung: {cmd_blacklist} add <Spieler> [Grund]"
usage-remove: "&cBenutzung: {cmd_blacklist} remove <Spieler>"
list-header: "&6Ticket-Blacklist &7({count} Einträge)"
list-empty: "&7Keine gesperrten Spieler."
list-entry: "&e{player} &7 &f{reason} &7(gesperrt von &e{by}&7)"
# ============================================================
# STATISTIKEN
# ============================================================
stats:
header: "&6Ticket Statistik"
total: "&eGesamt: &a{count}"
open: "&eOffen: &a{count}"
closed: "&eGeschlossen: &a{count} &7(historisch)"
forwarded: "&eWeitergeleitet: &a{count}"
ratings-header: "&6Support-Bewertungen &7(gesamt, historisch)"
ratings-summary: "&a👍 Positiv: &f{up} &c👎 Negativ: &f{down}"
ratings-percent: "&7Zufriedenheit: &e{percent}%"
staff-header: "&6Bewertungen nach Support-Mitarbeiter:"
staff-table-header: "&7 Name 👍 👎 Tickets Zufrieden"
staff-entry: "&e {name} &a{up} &c{down} &7{total} &e{percent}"
servers-header: "&6Tickets nach Server:"
server-entry: "&b {server}: &a{count}"
top-header: "&6Top-5 Ticket-Ersteller &7(historisch, persistent)"
top-empty: "&7Noch keine Daten vorhanden."
top-entry: " {medal} &f{name} &e{count} &7{label}"
top-ticket-label: "Ticket"
top-tickets-label: "Tickets"
cache-info: "&7Cache: &e{count} &7gecachte Ticket(s)"
# ============================================================
# TOP-ERSTELLER
# ============================================================
top:
header: "&6&lTop-5 Ticket-Ersteller"
empty: "&7Noch keine Daten vorhanden."
entry: "{medal} &f{name} &e{count} &7{label}"
footer: "&7(Zähler bleiben auch nach dem Löschen von Tickets erhalten)"
# ============================================================
# RELOAD
# ============================================================
reload:
success: "&aKonfiguration wurde neu geladen. &7(Kategorien, FAQs, Cache geleert)"
bungee-info: "&8[BungeeCord] &7Server: &b{server}"
# ============================================================
# MIGRATE / EXPORT / IMPORT
# ============================================================
migrate:
usage: "&cBenutzung: {cmd_migrate} <tomysql|tofile>"
export:
usage: "&cBenutzung: {cmd_export} <Dateiname>"
import:
usage: "&cBenutzung: {cmd_import} <Dateiname>"
# ============================================================
# FAQ-SYSTEM
# ============================================================
faq:
usage-add: "&cBenutzung: {cmd_faq} add <Frage> | <Antwort>"
usage-add-example: "&7Beispiel: &e{cmd_faq} add Wie erstelle ich ein Ticket? | Nutze {cmd_create}."
usage-edit: "&cBenutzung: {cmd_faq} edit <ID> <Frage> | <Antwort>"
usage-delete: "&cBenutzung: {cmd_faq} delete <ID>"
separator-missing: "&cTrenne Frage und Antwort mit &e|&c, z.B.:"
separator-example: "&e{cmd_faq} add Wie erstelle ich ein Ticket? | Nutze {cmd_create}."
separator-short: "&cTrenne Frage und Antwort mit &e|&c."
invalid-id: "&cUngültige FAQ-ID: &e{id}"
created: "&aFAQ &e#{id} &awurde erfolgreich erstellt!"
created-question: "&7Frage: &e{question}"
created-answer: "&7Antwort: &f{answer}"
updated: "&aFAQ &e#{id} &awurde erfolgreich aktualisiert!"
deleted: "&aFAQ &e#{id} &awurde gelöscht."
not-found: "&cFAQ &e#{id} &cwurde nicht gefunden."
reloaded: "&aFAQs wurden neu geladen. ({count} Einträge)"
list-header: "&6Häufige Fragen (FAQ) &7— {count} Einträge"
list-empty: "&7Noch keine FAQs vorhanden."
list-entry: "&e#{id} &f{question}"
list-answer: " &7→ &f{answer}"
list-admin-hint: "&7Befehle: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>"
unknown-sub: "&cUnbekannter FAQ-Befehl."
hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI."
admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list"
# ============================================================
# HILFE-MENÜ (/ticket ohne Argumente)
# ============================================================
help:
header: "&#00FFFF&lTicketSystem &7 Befehle"
create: "&e{cmd_create} [Kategorie] <Text> &7 Neues Ticket erstellen"
list: "&e{cmd_list} &7 Deine Tickets ansehen (GUI)"
comment: "&e{cmd_comment} <ID> <Text> &7 Nachricht zu einem Ticket"
rate: "&e{cmd_rate} <ID> <good|bad> &7 Support bewerten"
claim: "&e{cmd_claim} <ID> &7 Ticket annehmen"
close: "&e{cmd_close} <ID> [Kommentar] &7 Ticket schließen"
forward: "&e{cmd_forward} <ID> <Spieler> &7 Ticket weiterleiten"
blacklist: "&e{cmd_blacklist} <add|remove|list> [Spieler] [Grund] &7 Blacklist verwalten"
reload: "&e{cmd_reload} &7 Konfiguration neu laden"
stats: "&e{cmd_stats} &7 Statistiken anzeigen"
bungee-status: "&8[BungeeCord] &7Server: &b{server} &8| Cross-Server-Benachrichtigungen &aaktiv"
# ============================================================
# GUI-TEXTE (TicketGUI)
# ============================================================
gui:
# ── Chat-Nachrichten ────────────────────────────────────
no-archive-permission: "&cDu hast keine Berechtigung, das Archiv zu öffnen."
no-tickets: "&aDu hast aktuell keine Tickets."
filter-label: "&7Filter: {filter}"
ticket-removed: "&aDein Ticket &e#{id} &awurde aus deiner Übersicht entfernt."
ticket-remove-error: "&cFehler beim Entfernen des Tickets."
ticket-remove-claimed: "&cDu kannst dieses Ticket nicht löschen, da es bereits von einem Supporter bearbeitet wird."
teleport-success: "&7Du wurdest zu Ticket &e#{id} &7teleportiert."
world-not-loaded: "&cDie Welt des Tickets ist nicht geladen!"
teleport-disabled: "&cServerübergreifender Teleport ist in der Config deaktiviert.{hint}"
teleport-unknown: "&cServer des Tickets unbekannt Teleport nicht möglich."
bungee-connect: "&7Verbinde dich mit Server &b{server} &7für Ticket &e#{id}&7..."
bungee-connect-fail: "&cServer-Wechsel fehlgeschlagen. Bitte manuell verbinden."
no-delete-permission: "&cDu hast keine Berechtigung, Tickets permanent zu löschen."
only-closed-deletable: "&cNur geschlossene Tickets können permanent gelöscht werden."
ticket-deleted: "&aTicket &e#{id} &awurde permanent gelöscht."
ticket-delete-error: "&cFehler beim Löschen des Tickets."
already-closed: "&cDieses Ticket ist bereits geschlossen."
close-prompt-header: "&6Ticket #{id} schließen"
close-prompt-hint: "&7Gib einen Kommentar ein (&e- &7für keinen)."
close-prompt-cancel: "&7Abbrechen mit &ccancel"
close-cancelled: "&cAbgebrochen."
close-comment-echo: "&7Kommentar: &f{comment}"
no-priority-permission: "&cDu hast keine Berechtigung, die Priorität zu ändern."
priority-closed: "&cDie Priorität geschlossener Tickets kann nicht geändert werden."
priority-set: "&aPriorität auf {priority} &agesetzt."
priority-error: "&cFehler beim Ändern der Priorität."
comments-header: "&6Kommentare zu Ticket #{id}"
comments-empty: "&7Noch keine Kommentare vorhanden."
comments-entry: "&e{author} &7({time})&8: &f{message}"
# ── Inventar-Titel ──────────────────────────────────────
item:
title-admin: "§8§lTicket-Übersicht"
title-archive: "§8§lTicket-Archiv"
title-player: "§8§lMeine Tickets"
title-detail: "§8§lTicket-Details"
# ── Lore-Labels in Ticket-Items ─────────────────────
lore-creator: "§7Ersteller: §e{value}"
lore-message: "§7Anliegen: §f{value}"
lore-created: "§7Erstellt: §e{value}"
lore-server: "§7Server: §b{value}"
lore-world: "§7Welt: §e{value}"
lore-position: "§7Position: §e{value}"
lore-category: "§7Kategorie: {value}"
lore-priority: "§7Priorität: {value}"
lore-claimed-by: "§7Angenommen von: §a{value}"
lore-claimed-at: "§7Angenommen am: §a{value}"
lore-closed-at: "§7Geschlossen am: §c{value}"
lore-comment: "§7Kommentar: §f{value}"
lore-rating-none: "§7Keine Bewertung"
lore-rating-good: "§a👍 Positiv"
lore-rating-bad: "§c👎 Negativ"
lore-rating-label: "§7Bewertung: {value}"
lore-player-deleted: "§cSpieler hat Ticket gelöscht."
# ── Admin-Listen-Item ───────────────────────────────
list-click: "§e§l» KLICKEN für Details"
# ── Spieler-Listen-Item ─────────────────────────────
player-delete-hint: "§c§l» KLICKEN zum Löschen"
player-delete-desc: "§7Entferne dieses Ticket aus deiner Übersicht."
player-in-progress: "§e» Ticket wird bearbeitet..."
player-no-delete: "§7Kann nicht mehr gelöscht werden."
player-rate-hint: "§e» /ticket rate {id} good/bad"
player-rated-good: "§7Bewertet: §a👍"
player-rated-bad: "§7Bewertet: §c👎"
player-comment-label: "§7Kommentar des Supports:"
# ── Detail-Aktions-Buttons ──────────────────────────
btn-teleport: "§b§lTeleportieren"
btn-teleport-lore1: "§7Teleportiert dich zur"
btn-teleport-lore2: "§7Position des Tickets."
btn-teleport-bungee1: "§7Teleportiert dich zur Ticket-Position."
btn-teleport-same: "§7Dieser Server §a(direkt)"
btn-teleport-other: "§7Ziel-Server: §b{server}"
btn-teleport-local: "§8Lokaler Teleport"
btn-teleport-switch: "§8Server-Wechsel erforderlich"
btn-teleport-unknown: "§cServer unbekannt"
btn-teleport-disabled: "§8Teleport deaktiviert"
btn-teleport-dis1: "§7Im BungeeCord-Modus ist"
btn-teleport-dis2: "§7Teleportation deaktiviert."
btn-teleport-dis3: "§8(bungee-teleport-enabled: false)"
btn-teleport-server: "§7Ticket-Server: §b{server}"
btn-teleport-noserver: "§7Server unbekannt"
btn-claim: "§a§lTicket annehmen"
btn-claim-lore1: "§7Nimmt dieses Ticket an"
btn-claim-lore2: "§7und markiert es als bearbeitet."
btn-claimed: "§8Bereits angenommen"
btn-claimed-lore1: "§7Dieses Ticket wurde bereits"
btn-claimed-lore2: "§7angenommen."
btn-delete: "§4§lTicket permanent löschen"
btn-delete-lore1: "§7Löscht dieses Ticket"
btn-delete-lore2: "§7unwiderruflich aus der Datenbank."
btn-delete-warn: "§c§lACHTUNG: §cNicht rückgängig zu machen!"
btn-close: "§c§lTicket schließen"
btn-close-lore1: "§7Schließt das Ticket."
btn-close-lore2: "§eKlick für Kommentar-Eingabe."
btn-closed: "§8Bereits geschlossen"
btn-closed-lore1: "§7Dieses Ticket ist bereits"
btn-closed-lore2: "§7geschlossen."
btn-comments: "§e§lKommentare anzeigen"
btn-comments-lore1: "§7Zeigt alle Nachrichten/Antworten"
btn-comments-lore2: "§7zu diesem Ticket im Chat."
btn-prio: "§6§lPriorität ändern"
btn-prio-current: "§7Aktuell: {value}"
btn-prio-click: "§8Klicken zum Wechseln"
btn-back: "§7§lZurück"
btn-back-lore: "§7Zurück zur Ticket-Übersicht."
# ── Navigation ──────────────────────────────────────
nav-prev: "§7§l◄ Zurück"
nav-prev-lore: "§7Seite {page} von {total}"
nav-next: "§7§lWeiter ►"
nav-next-lore: "§7Seite {page} von {total}"
nav-page: "§8Seite {page}/{total}"
nav-page-lore: "§7Gesamt: {count} Tickets auf dieser Seite"
nav-archive: "§7§lGeschlossene Tickets"
nav-archive-lore1: "§7Zeigt alle abgeschlossenen"
nav-archive-lore2: "§7Tickets im Archiv an."
nav-back-overview: "§7§lZurück zur Übersicht"
nav-back-ov-lore: "§7Zeigt alle offenen Tickets."
nav-filter: "§e§lKategorie-Filter"
nav-filter-current: "§7Aktuell: {value}"
nav-filter-click: "§8Klicken zum Wechseln"
nav-filter-all: "§7Alle (kein Filter)"
# ── FAQ GUI Texte (Neu) ─────────────────────────────────
faq:
title: "&#FFD700&lHäufige Fragen (FAQ)"
admin-title: "§8§lFAQ verwalten"
action-title: "§8§lFAQ Aktionen"
add-button: "§a§lNeues FAQ hinzufügen"
add-lore-1: "§7Fügt einen neuen FAQ-Eintrag hinzu."
add-lore-2: "§7Du wirst nach Frage und Antwort gefragt."
edit-button: "§a§lFAQ bearbeiten"
edit-lore-1: "§7Ändere Frage und Antwort"
edit-lore-2: "§7dieses FAQ-Eintrags."
delete-button: "§c§lFAQ löschen"
delete-lore-1: "§7Löscht diesen FAQ-Eintrag."
delete-lore-2: "§c§lACHTUNG: §cNicht rückgängig zu machen!"
delete-error: "§cFehler: FAQ #{id} konnte nicht gelöscht werden."
back-button: "§7§lZurück"
back-lore: "§7Zurück zur FAQ-Übersicht."
click-detail: "§e» Klicken für mehr Details im Chat"
click-edit: "§e» Klicken zum Bearbeiten / Löschen"
nav-prev: "§7§l◄ Zurück"
nav-prev-lore: "§7Seite {page} von {total}"
nav-next: "§7§lWeiter ►"
nav-next-lore: "§7Seite {page} von {total}"
nav-page: "§8Seite {page}/{total}"
nav-page-lore: "§7Gesamt: {count} FAQ(s)"
chat-create-title: "§6§lNeues FAQ erstellen"
chat-question-prompt: "§7Gib die §eFrage §7ein (oder §ccancel§7):"
chat-answer-prompt: "§7Gib jetzt die §eAntwort §7ein (oder §ccancel§7):"
chat-edit-title: "§6§lFAQ #{id} bearbeiten"
chat-current-question: "§7Aktuelle Frage: §e{question}"
lore-id: "§7FAQ #{id}"
lore-separator: "§8§m "
question-set: "§7Frage gesetzt: §e{question}"
internal-error: "§cInterner Fehler beim Bearbeiten des FAQs."
# ============================================================
# JOIN-LISTENER
# ============================================================
join:
open-tickets: "&eEs gibt noch &6{count} &eoffene Ticket(s)!"
open-tickets-hint: "&7» Tippe &e{cmd_list} &7für die Übersicht."
teleport-world-missing: "&cTeleport-Zielwelt &e{world} &cnicht gefunden!"
teleport-success: "&7Du wurdest zur Ticket-Position teleportiert. &8({coords})"
pending-header: "&6Ticket-Benachrichtigungen &7(während du offline warst):"
# ============================================================
# UPDATE-CHECKER
# ============================================================
update:
available-console: "Neue Version verfügbar: {new} (aktuell: {current})"
available-bar: "===================================================="
available-line1: "&6[TicketSystem] &eNEUES UPDATE VERFÜGBAR: v{version}"
available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757"

View File

@@ -0,0 +1,461 @@
# ============================================================
# TicketSystem Language File English (en)
#
# All plugin messages can be customized here.
# Color codes: & (e.g. &a = green, &c = red, &e = yellow, &7 = grey)
# HEX-Codes: &#RRGGBB (e.g. &#00AA00 = Green)
# Placeholders are written in curly braces: {id}, {player}, ...
#
# Switch language in config.yml: language: en
# Switch command language in config.yml: command-language: de | en | both
#
# {cmd_X} is automatically replaced based on command-language, e.g.:
# command-language: de → /ticket erstellen
# command-language: en → /ticket create
# command-language: both → /ticket create (erstellen)
# ============================================================
prefix: "&#5555FF[&fTicket&#5555FF] &r"
# ============================================================
# GENERAL ERRORS & HINTS
# ============================================================
general:
no-permission: "&cYou don't have permission to do this!"
console-only: "&cThis command can only be used by players."
invalid-id: "&cInvalid ID!"
invalid-player-id: "&cInvalid ticket ID: &e{id}"
player-not-found: "&cPlayer not found!"
ticket-not-found: "&cTicket not found!"
already-claimed: "&cThis ticket has already been claimed!"
no-open-tickets: "&aThere are no open tickets right now."
cooldown: "&cPlease wait &e{seconds} seconds &cbefore creating a new ticket."
separator: "&#777777&m "
# ============================================================
# SYSTEM (Export, Import, Migration, Archive, Validation)
# ============================================================
system:
export-success: "&aExport successful: &e{count} &atickets exported to &e{file}&a."
export-fail: "&cExport failed or no tickets found."
import-success: "&aImport successful: &e{count} &atickets imported."
import-fail: "&cImport failed or no tickets found."
migration-success: "&aMigration complete: &e{count} &atickets migrated."
migration-fail: "&cNo tickets migrated or an error occurred."
archive-success: "&aArchiving complete: &e{count} &atickets archived."
archive-fail: "&cNo closed tickets found to archive."
file-not-found: "&cFile not found: &e{file}"
unknown-mode: "&cUnknown mode! Use: tomysql or tofile"
validation-warning: "&c&e{count} &cinvalid tickets were found during loading."
db-create-error: "&cFailed to create the ticket!"
# ============================================================
# TICKET ACTIONS
# ============================================================
ticket:
created: "&aTicket &e#{id} &ahas been created successfully!"
created-category: "&aTicket &e#{id} &acreated! &7Category: {category}"
claimed: "&aYou have claimed ticket &e#{id} &afrom &e{player}&a."
claimed-notify: "&aYour ticket &e#{id} &ahas been claimed by &e{claimer}&a."
closed: "&aTicket &e#{id} &ahas been closed."
closed-notify: "&aYour ticket &e#{id} &ahas been closed."
forwarded: "&aTicket &e#{id} &ahas been forwarded to &e{player}&a."
forwarded-notify: "&eYou have received a forwarded ticket from &6{player}&e. &7(ID: {id})"
forwarded-creator: "&eYour ticket &6#{id} &ehas been forwarded to &b{supporter}&e."
new-notify: "&e{player} &acreated a new ticket: &7{message} &7(ID: &e{id}&7)"
close-comment-label: "&7Support comment: &f{comment}"
close-comment-short: "&7Comment: &f{comment}"
pending-closed: "&e[Ticket #{id}] &7Your ticket has been closed.{comment}{rating}"
pending-closed-comment: " &7Comment: &f{comment}"
pending-closed-rating: " &7Rating: &e{cmd_rate} {id} good/bad"
# ============================================================
# NOTIFICATIONS (Team / GUI hint)
# ============================================================
notify:
gui-hint: "&7» Click &e{cmd_list} &7to open the overview."
team-category: " §7[§r{category}§7]"
team-priority: " §7Priority: §r{priority}"
team-server: " §7Server: §b{server}"
# ============================================================
# TICKET CREATE
# ============================================================
create:
usage: "&cUsage: {cmd_create} [category] [priority] <description>"
categories-hint: "&7Categories: &ebug&7, &equestion&7, &ecomplaint&7, &eother&7, &egeneral"
priorities-hint: "&7Priorities: &alow&7, &enormal&7, &6high&7, &curgent"
max-tickets: "&cYou already have &e{max} &copen ticket(s). Please wait until your ticket is processed."
no-description: "&cPlease provide a description for your ticket."
too-long: "&cYour description is too long! Maximum {max} characters."
blacklist-blocked: "&cYou have been banned from the ticket system and cannot create tickets."
category-invalid: "&cUnknown category: &e{input}&c. Available categories: &e{categories}"
# ============================================================
# CLAIM / CLOSE / FORWARD
# ============================================================
claim:
usage: "&cUsage: {cmd_claim} <ID>"
close:
usage: "&cUsage: {cmd_close} <ID> [comment]"
forward:
usage: "&cUsage: {cmd_forward} <ID> <player>"
bungee-offline: "&7[BungeeCord] Player &e{player} &7is not online on this server."
local-not-found: "&cPlayer not found!"
# ============================================================
# COMMENTS
# ============================================================
comment:
saved: "&aYour comment on ticket &e#{id} &ahas been saved."
usage: "&cUsage: {cmd_comment} <ID> <message>"
too-long: "&cMessage too long! Maximum 500 characters."
no-permission: "&cYou can only comment on your own tickets."
error: "&cFailed to save the comment."
notify-online: "&e[Ticket #{id}] &f{author} &7commented: &f{message}"
notify-offline: "&e[Ticket #{id}] &f{author} &7commented while you were offline: &f{message}"
claimer-offline: "&e[Ticket #{id}] &f{author} &7commented on your claimed ticket (offline): &f{message}"
# ============================================================
# RATINGS
# ============================================================
rating:
saved-good: "&aThank you for your rating! &a👍 Positive"
saved-bad: "&aThank you for your rating! &c👎 Negative"
already-rated: "&cYou have already rated this ticket."
not-yours: "&cYou can only rate your own tickets."
disabled: "&cRatings are currently disabled."
not-closeable: "&cRating could not be saved. Is the ticket still open?"
usage: "&cUsage: {cmd_rate} <ID> <good|bad>"
invalid: "&cInvalid rating! Use &egood &cor &ebad&c."
prompt-header: "&#777777&m "
prompt-title: "&6How satisfied are you with the support?"
prompt-good: "&a{cmd_rate} {id} good &7 👍 Good"
prompt-bad: "&c{cmd_rate} {id} bad &7 👎 Bad"
prompt-footer: "&#777777&m "
# ============================================================
# SET PRIORITY
# ============================================================
setpriority:
usage: "&cUsage: {cmd_setpriority} <ID> <low|normal|high|urgent>"
disabled: "&cThe priority system is disabled."
invalid: "&cInvalid priority! Valid: &alow&7, &enormal&7, &6high&7, &curgent"
success: "&aPriority of ticket &e#{id} &ahas been set to {priority}&a."
not-found: "&cTicket &e#{id} &cwas not found."
# ============================================================
# BLACKLIST
# ============================================================
blacklist:
added: "&a{player} &ahas been added to the ticket blacklist. &7Reason: &e{reason}"
removed: "&a{player} &ahas been removed from the blacklist."
already: "&cPlayer is already on the blacklist."
not-found: "&cPlayer was not on the blacklist."
usage: "&cUsage: {cmd_blacklist} <add|remove|list> [player] [reason]"
usage-add: "&cUsage: {cmd_blacklist} add <player> [reason]"
usage-remove: "&cUsage: {cmd_blacklist} remove <player>"
list-header: "&6Ticket Blacklist &7({count} entries)"
list-empty: "&7No banned players."
list-entry: "&e{player} &7 &f{reason} &7(banned by &e{by}&7)"
# ============================================================
# STATISTICS
# ============================================================
stats:
header: "&6Ticket Statistics"
total: "&eTotal: &a{count}"
open: "&eOpen: &a{count}"
closed: "&eClosed: &a{count} &7(historical)"
forwarded: "&eForwarded: &a{count}"
ratings-header: "&6Support Ratings &7(total, historical)"
ratings-summary: "&a👍 Positive: &f{up} &c👎 Negative: &f{down}"
ratings-percent: "&7Satisfaction: &e{percent}%"
staff-header: "&6Ratings by support staff:"
staff-table-header: "&7 Name 👍 👎 Tickets Satisfied"
staff-entry: "&e {name} &a{up} &c{down} &7{total} &e{percent}"
servers-header: "&6Tickets by server:"
server-entry: "&b {server}: &a{count}"
top-header: "&6Top-5 Ticket Creators &7(historical, persistent)"
top-empty: "&7No data available yet."
top-entry: " {medal} &f{name} &e{count} &7{label}"
top-ticket-label: "Ticket"
top-tickets-label: "Tickets"
cache-info: "&7Cache: &e{count} &7cached ticket(s)"
# ============================================================
# TOP CREATORS
# ============================================================
top:
header: "&6&lTop-5 Ticket Creators"
empty: "&7No data available yet."
entry: "{medal} &f{name} &e{count} &7{label}"
footer: "&7(Counts are kept even after tickets are deleted)"
# ============================================================
# RELOAD
# ============================================================
reload:
success: "&aConfiguration reloaded. &7(Categories, FAQs, cache cleared)"
bungee-info: "&8[BungeeCord] &7Server: &b{server}"
# ============================================================
# MIGRATE / EXPORT / IMPORT
# ============================================================
migrate:
usage: "&cUsage: {cmd_migrate} <tomysql|tofile>"
export:
usage: "&cUsage: {cmd_export} <filename>"
import:
usage: "&cUsage: {cmd_import} <filename>"
# ============================================================
# FAQ SYSTEM
# ============================================================
faq:
usage-add: "&cUsage: {cmd_faq} add <question> | <answer>"
usage-add-example: "&7Example: &e{cmd_faq} add How do I create a ticket? | Use {cmd_create}."
usage-edit: "&cUsage: {cmd_faq} edit <ID> <question> | <answer>"
usage-delete: "&cUsage: {cmd_faq} delete <ID>"
separator-missing: "&cSeparate question and answer with &e|&c, e.g.:"
separator-example: "&e{cmd_faq} add How do I create a ticket? | Use {cmd_create}."
separator-short: "&cSeparate question and answer with &e|&c."
invalid-id: "&cInvalid FAQ ID: &e{id}"
created: "&aFAQ &e#{id} &ahas been created successfully!"
created-question: "&7Question: &e{question}"
created-answer: "&7Answer: &f{answer}"
updated: "&aFAQ &e#{id} &ahas been updated successfully!"
deleted: "&aFAQ &e#{id} &ahas been deleted."
not-found: "&cFAQ &e#{id} &cwas not found."
reloaded: "&aFAQs reloaded. ({count} entries)"
list-header: "&6Frequently Asked Questions &7— {count} entries"
list-empty: "&7No FAQs available yet."
list-entry: "&e#{id} &f{question}"
list-answer: " &7→ &f{answer}"
list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>"
unknown-sub: "&cUnknown FAQ command."
hint-open: "&7Use &e{cmd_faq} &7to open the GUI."
admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list"
# ============================================================
# HELP MENU (/ticket without arguments)
# ============================================================
help:
header: "&#00FF00&lTicketSystem &7 Commands"
create: "&e{cmd_create} [category] <text> &7 Create a new ticket"
list: "&e{cmd_list} &7 View your tickets (GUI)"
comment: "&e{cmd_comment} <ID> <text> &7 Add a message to a ticket"
rate: "&e{cmd_rate} <ID> <good|bad> &7 Rate the support"
claim: "&e{cmd_claim} <ID> &7 Claim a ticket"
close: "&e{cmd_close} <ID> [comment] &7 Close a ticket"
forward: "&e{cmd_forward} <ID> <player> &7 Forward a ticket"
blacklist: "&e{cmd_blacklist} <add|remove|list> [player] [reason] &7 Manage blacklist"
reload: "&e{cmd_reload} &7 Reload configuration"
stats: "&e{cmd_stats} &7 Show statistics"
bungee-status: "&8[BungeeCord] &7Server: &b{server} &8| Cross-server notifications &aactive"
# ============================================================
# GUI TEXTS (TicketGUI)
# ============================================================
gui:
# ── Chat messages ────────────────────────────────────────
no-archive-permission: "&cYou don't have permission to open the archive."
no-tickets: "&aYou don't have any tickets right now."
filter-label: "&7Filter: {filter}"
ticket-removed: "&aYour ticket &e#{id} &ahas been removed from your overview."
ticket-remove-error: "&cFailed to remove the ticket."
ticket-remove-claimed: "&cYou cannot delete this ticket because it is already being handled by a supporter."
teleport-success: "&7You have been teleported to ticket &e#{id}&7."
world-not-loaded: "&cThe world of this ticket is not loaded!"
teleport-disabled: "&cCross-server teleport is disabled in the config.{hint}"
teleport-unknown: "&cTicket server unknown teleport not possible."
bungee-connect: "&7Connecting to server &b{server} &7for ticket &e#{id}&7..."
bungee-connect-fail: "&cServer switch failed. Please connect manually."
no-delete-permission: "&cYou don't have permission to permanently delete tickets."
only-closed-deletable: "&cOnly closed tickets can be permanently deleted."
ticket-deleted: "&aTicket &e#{id} &ahas been permanently deleted."
ticket-delete-error: "&cFailed to delete the ticket."
already-closed: "&cThis ticket is already closed."
close-prompt-header: "&6Close ticket #{id}"
close-prompt-hint: "&7Enter a comment (&e- &7for none)."
close-prompt-cancel: "&7Type &ccancel &7to abort."
close-cancelled: "&cCancelled."
close-comment-echo: "&7Comment: &f{comment}"
no-priority-permission: "&cYou don't have permission to change the priority."
priority-closed: "&cThe priority of closed tickets cannot be changed."
priority-set: "&aPriority set to {priority}&a."
priority-error: "&cFailed to change the priority."
comments-header: "&6Comments on ticket #{id}"
comments-empty: "&7No comments yet."
comments-entry: "&e{author} &7({time})&8: &f{message}"
# ── Inventory titles ─────────────────────────────────────
item:
title-admin: "§8§lTicket Overview"
title-archive: "§8§lTicket Archive"
title-player: "§8§lMy Tickets"
title-detail: "§8§lTicket Details"
# ── Lore labels in ticket items ─────────────────────
lore-creator: "§7Creator: §e{value}"
lore-message: "§7Message: §f{value}"
lore-created: "§7Created: §e{value}"
lore-server: "§7Server: §b{value}"
lore-world: "§7World: §e{value}"
lore-position: "§7Position: §e{value}"
lore-category: "§7Category: {value}"
lore-priority: "§7Priority: {value}"
lore-claimed-by: "§7Claimed by: §a{value}"
lore-claimed-at: "§7Claimed at: §a{value}"
lore-closed-at: "§7Closed at: §c{value}"
lore-comment: "§7Comment: §f{value}"
lore-rating-none: "§7No rating"
lore-rating-good: "§a👍 Positive"
lore-rating-bad: "§c👎 Negative"
lore-rating-label: "§7Rating: {value}"
lore-player-deleted: "§cPlayer deleted this ticket."
# ── Admin list item ─────────────────────────────────
list-click: "§e§l» CLICK for details"
# ── Player list item ────────────────────────────────
player-delete-hint: "§c§l» CLICK to remove"
player-delete-desc: "§7Remove this ticket from your overview."
player-in-progress: "§e» Ticket is being processed..."
player-no-delete: "§7Cannot be deleted anymore."
player-rate-hint: "§e» /ticket rate {id} good/bad"
player-rated-good: "§7Rated: §a👍"
player-rated-bad: "§7Rated: §c👎"
player-comment-label: "§7Support comment:"
# ── Detail action buttons ───────────────────────────
btn-teleport: "§b§lTeleport"
btn-teleport-lore1: "§7Teleports you to the"
btn-teleport-lore2: "§7location of this ticket."
btn-teleport-bungee1: "§7Teleports you to the ticket location."
btn-teleport-same: "§7This server §a(direct)"
btn-teleport-other: "§7Target server: §b{server}"
btn-teleport-local: "§8Local teleport"
btn-teleport-switch: "§8Server switch required"
btn-teleport-unknown: "§cServer unknown"
btn-teleport-disabled: "§8Teleport disabled"
btn-teleport-dis1: "§7In BungeeCord mode"
btn-teleport-dis2: "§7teleportation is disabled."
btn-teleport-dis3: "§8(bungee-teleport-enabled: false)"
btn-teleport-server: "§7Ticket server: §b{server}"
btn-teleport-noserver: "§7Server unknown"
btn-claim: "§a§lClaim ticket"
btn-claim-lore1: "§7Claims this ticket and"
btn-claim-lore2: "§7marks it as being processed."
btn-claimed: "§8Already claimed"
btn-claimed-lore1: "§7This ticket has already"
btn-claimed-lore2: "§7been claimed."
btn-delete: "§4§lPermanently delete ticket"
btn-delete-lore1: "§7Deletes this ticket"
btn-delete-lore2: "§7irreversibly from the database."
btn-delete-warn: "§c§lWARNING: §cThis cannot be undone!"
btn-close: "§c§lClose ticket"
btn-close-lore1: "§7Closes the ticket."
btn-close-lore2: "§eClick to enter a comment."
btn-closed: "§8Already closed"
btn-closed-lore1: "§7This ticket is already"
btn-closed-lore2: "§7closed."
btn-comments: "§e§lShow comments"
btn-comments-lore1: "§7Shows all messages/replies"
btn-comments-lore2: "§7for this ticket in chat."
btn-prio: "§6§lChange priority"
btn-prio-current: "§7Current: {value}"
btn-prio-click: "§8Click to cycle"
btn-back: "§7§lBack"
btn-back-lore: "§7Back to ticket overview."
# ── Navigation ──────────────────────────────────────
nav-prev: "§7§l◄ Previous"
nav-prev-lore: "§7Page {page} of {total}"
nav-next: "§7§lNext ►"
nav-next-lore: "§7Page {page} of {total}"
nav-page: "§8Page {page}/{total}"
nav-page-lore: "§7Total: {count} tickets on this page"
nav-archive: "§7§lClosed Tickets"
nav-archive-lore1: "§7Shows all completed"
nav-archive-lore2: "§7tickets in the archive."
nav-back-overview: "§7§lBack to Overview"
nav-back-ov-lore: "§7Shows all open tickets."
nav-filter: "§e§lCategory Filter"
nav-filter-current: "§7Current: {value}"
nav-filter-click: "§8Click to cycle"
nav-filter-all: "§7All (no filter)"
# ── FAQ GUI Texts (New) ─────────────────────────────────
faq:
title: "&#00FF00&lFrequently Asked Questions (FAQ)"
admin-title: "§8§lManage FAQ"
action-title: "§8§lFAQ Actions"
add-button: "§a§lAdd new FAQ"
add-lore-1: "§7Adds a new FAQ entry."
add-lore-2: "§7You will be asked for question and answer."
edit-button: "§a§lEdit FAQ"
edit-lore-1: "§7Change question and answer"
edit-lore-2: "§7of this FAQ entry."
delete-button: "§c§lDelete FAQ"
delete-lore-1: "§7Deletes this FAQ entry."
delete-lore-2: "§c§lWARNING: §cCannot be undone!"
delete-error: "§cError: FAQ #{id} could not be deleted."
back-button: "§7§lBack"
back-lore: "§7Back to FAQ overview."
click-detail: "§e» Click for more details in chat"
click-edit: "§e» Click to edit / delete"
nav-prev: "§7§l◄ Previous"
nav-prev-lore: "§7Page {page} of {total}"
nav-next: "§7§lNext ►"
nav-next-lore: "§7Page {page} of {total}"
nav-page: "§8Page {page}/{total}"
nav-page-lore: "§7Total: {count} FAQ(s)"
chat-create-title: "§6§lCreate new FAQ"
chat-question-prompt: "§7Enter the §eQuestion §7(or §ccancel§7):"
chat-answer-prompt: "§7Now enter the §eAnswer §7(or §ccancel§7):"
chat-edit-title: "§6§lEdit FAQ #{id}"
chat-current-question: "§7Current Question: §e{question}"
lore-id: "§7FAQ #{id}"
lore-separator: "§8§m "
question-set: "§7Question set: §e{question}"
internal-error: "§cInternal error while editing the FAQ."
# ============================================================
# JOIN LISTENER
# ============================================================
join:
open-tickets: "&eThere are still &6{count} &eopen ticket(s)!"
open-tickets-hint: "&7» Type &e{cmd_list} &7for the overview."
teleport-world-missing: "&cTeleport target world &e{world} &cnot found!"
teleport-success: "&7You have been teleported to the ticket location. &8({coords})"
pending-header: "&6Ticket notifications &7(while you were offline):"
# ============================================================
# UPDATE CHECKER
# ============================================================
update:
available-console: "New version available: {new} (current: {current})"
available-bar: "===================================================="
available-line1: "&6[TicketSystem] &eNEW UPDATE AVAILABLE: v{version}"
available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757"

View File

@@ -1,25 +1,62 @@
name: TicketSystem name: TicketSystem
version: 1.0.1 version: 1.0.8
main: de.ticketsystem.TicketPlugin main: de.ticketsystem.TicketPlugin
api-version: 1.20 api-version: 1.20
author: M_Viper author: M_Viper
description: Ingame Support Ticket System with MySQL description: Ingame Support Ticket System with MySQL
# ── BungeeCord Plugin-Messaging-Kanäle ───────────────────────────────────────
# PFLICHTFELD für Cross-Server-Benachrichtigungen!
channels:
- BungeeCord
- ticketsystem:notify
commands: commands:
ticket: ticket:
description: Ticket System Hauptbefehl description: TicketSystem Hauptbefehl
usage: /ticket <create|list|claim|close|forward|reload> usage: |
/ticket create [Kategorie] <Text>
/ticket list
/ticket comment <ID> <Nachricht>
/ticket rate <ID> <good|bad>
/ticket claim <ID>
/ticket close <ID> [Kommentar]
/ticket forward <ID> <Spieler>
/ticket blacklist <add|remove|list> [Spieler] [Grund]
/ticket stats
/ticket archive
/ticket reload
aliases: [t, support] aliases: [t, support]
permissions: permissions:
# ── Spieler-Permissions ───────────────────────────────────────────────────
ticket.create: ticket.create:
description: Spieler kann Tickets erstellen description: Spieler kann Tickets erstellen und kommentieren
default: true default: true
# ── Supporter-Permissions ─────────────────────────────────────────────────
ticket.support: ticket.support:
description: Supporter kann Tickets einsehen und claimen description: Supporter kann Tickets einsehen, claimen und schließen
default: false default: false
ticket.archive:
description: Zugriff auf das Ticket-Archiv (öffnen, einsehen, permanent löschen)
default: false
# ── Admin-Permissions ────────────────────────────────────────────────────
ticket.admin: ticket.admin:
description: Admin hat vollen Zugriff inkl. Weiterleitung und Reload description: >
Admin hat vollen Zugriff: Weiterleiten, Blacklist verwalten,
Statistiken, Reload, Archiv, Export/Import, Migration
default: op default: op
children:
ticket.support: true
ticket.blacklist: true
ticket.blacklist:
description: Kann Spieler zur Ticket-Blacklist hinzufügen und entfernen
default: false