using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.UI; namespace Oxide.Plugins { [Info("Help Center UI", "gamezoneone", "1.5.2")] [Description("In-game help system with categories, sub-pages, editor UI and optional chat output.")] public class HelpCenterUI : RustPlugin { private const string UiNameMain = "HelpCenterUI.Main"; private const string UiNameBg = "HelpCenterUI.Bg"; private const string UiNameEdit = "HelpCenterUI.Edit"; private const string PermAdmin = "helpcenterui.admin"; private const string PermEdit = "helpcenterui.edit"; private const int MaxMainCategories = 5; private const int ContentLinesPerPart = 17; private const int SubCategoriesPerPage = 12; /// Text-Panel vertikal: unter den beiden Überschrift-Zeilen, über der Fußleiste. private const float MainContentMinY = 0.088f; private const float MainContentMaxY = 0.688f; private const float MainContentMaxYHardCap = 0.700f; private const bool AutoCommandsIncludeConsole = false; private const string FullscreenRawImageSprite = "assets/content/textures/generic/fulltransparent.tga"; private const string ImageLibraryBackgroundName = "HelpCenterUI.Background"; private const float PendingDeleteConfirmSeconds = 12f; [PluginReference] private Plugin ImageLibrary; private PluginConfig _config; private readonly Dictionary _uiStates = new Dictionary(); private readonly HashSet _helpBgActive = new HashSet(); private sealed class EditDraft { public string CategoryKey; public string PageKey; public string Title = string.Empty; public string Content = string.Empty; } private readonly Dictionary _editDrafts = new Dictionary(); private sealed class PendingDeleteConfirm { public string Kind; public string CategoryKey; public string PageKey; public float Time; } private readonly Dictionary _pendingDeletes = new Dictionary(); private string _backgroundImageRegisteredForUrl; private string _cachedPublicCommandsText = "Befehlsindex wird erstellt ..."; private string _cachedInternalCommandsText = "Interner Befehlsindex wird erstellt ..."; private string _cachedTeamRosterText = "Teamliste wird erstellt ..."; private class PlayerUiState { public string CategoryKey; public string PageKey; public int ContentPart; public int SubCategoryPage; } private class PluginConfig { [JsonProperty("Commands")] public Dictionary Commands = new Dictionary { ["help"] = "", ["info"] = "", ["helppage"] = "", ["wiki"] = "commands:bindings", ["events"] = "events:overview", ["regeln"] = "server:regeln", ["befehle"] = "commands:teleport", ["markt"] = "commands:economy", ["shops"] = "commands:economy" }; [JsonProperty("Main Category Order (max 5)")] public List MainCategoryOrder = new List { "start", "server", "commands", "events", "community" }; [JsonProperty("Fullscreen Layout")] public bool FullscreenLayout = true; [JsonProperty("Background Image Url")] public string BackgroundImageUrl = "https://pic.gamezoneone.de/api/media/4g7wkwbb.png"; [JsonProperty("Background Global Darken")] public float BackgroundGlobalDarken = 0.45f; [JsonProperty("Background Image Alpha")] public float BackgroundImageAlpha = 1f; /// Beim Join automatisch /help öffnen (null = Standard ja). [JsonProperty("Open Help On Connect")] public bool? OpenHelpOnConnect; /// Verzögerung nach Spawn, damit Client/UI bereit ist (Sekunden). [JsonProperty("Open Help Delay Seconds")] public float? OpenHelpDelaySeconds; [JsonProperty("Pages")] public Dictionary Pages = new Dictionary { ["start"] = new CategoryConfig { Title = "Start", Entries = new Dictionary { ["welcome"] = new PageConfig { Title = "Willkommen", Content = "Willkommen auf GameZoneOne!\n\n" + "Wir sind ein deutscher Rust-Server mit klarem Fokus: entspanntes PvE, faire Regeln und spannende PvP-Hotspots – wenn du sie suchst. Vor jedem Map-Wipe gibt es eine Purge-Phase: dann wird es überall härter – plane rechtzeitig mit.\n\n" + "Was dich erwartet (Kurzüberblick):\n" + "• Sammeln: erhöhte Raten (Gather)\n" + "• Rhythmus: Map-Wipe etwa alle 3 Wochen (Details im Discord)\n" + "• Community: Discord ist der beste Ort für Termine, Events und Support\n\n" + "Links:\n" + "• Discord: discord.gg/szBd7ZD5Bq\n" + "• Website: www.gamezoneone.de\n\n" + "Dieses Menü öffnest du mit /help (Kurzform /info). Unten rechts kannst du die aktuelle Seite mit „In Chat anzeigen“ in den Chat kopieren – praktisch, wenn du etwas nachlesen willst.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["erste_schritte"] = new PageConfig { Title = "Erste Schritte", Content = "Deine ersten Minuten – ohne Stress:\n\n" + "1) Sammeln & Überleben\n" + "• Holz von Bäumen, Steine am Boden, ggf. Tier für Tuch\n" + "• Crafte eine Steinaxt, dann Spitzhacke und eine Waffe (Bogen reicht am Anfang)\n\n" + "2) Sichere Basis\n" + "• Kleine 1×1 oder 2×2 reicht zum Start – Hauptsache: Tool Cupboard (TC) mit Ressourcen füllen\n" + "• Bett oder Schlafsack setzen = eigener Respawn\n" + "• Türen mit Schloss oder Code-Schloss sichern\n\n" + "3) Recycler & Loot\n" + "• Monumente mit Recycler nutzen (Komponenten → Scrap)\n" + "• Vorsicht in PvP-Zonen: andere Spieler dürfen dort angreifen\n\n" + "4) Server kennenlernen\n" + "• /help durchstöbern (du bist hier schon richtig)\n" + "• /link für Discord – dann oft /daily und Community-Features\n\n" + "Tipp: Spiel mit Kopf, nicht mit Ego. Fragen? Discord oder /report bei echten Problemen.", FontSize = 14, Center = false, IncreaseHeightValue = 0 } } }, ["server"] = new CategoryConfig { Title = "Server", Entries = new Dictionary { ["regeln"] = new PageConfig { Title = "Regeln & Fairplay", Content = "Ziel: für alle ein faires, respektvolles Spiel. Regeln können sich weiterentwickeln – maßgeblich sind Ankündigungen im Discord.\n\n" + "Allgemein\n" + "• Kein Cheating, Makros mit Vorteil, Exploits oder absichtliches Glitchen\n" + "• Keine Beleidigungen, Hate, Rassismus, Sexismus, Homophobie – null Toleranz\n" + "• Kein Doxxing, keine echten Drohungen\n" + "• Kein Spam, kein Werben für andere Server/Communities\n" + "• Anweisungen des Teams sind einzuhalten\n\n" + "PvE & Basen\n" + "• Fremde Basen nicht ausrauben oder zerstören (PvE-Phase)\n" + "• Keine Türme oder Belästigungen direkt an fremden Bases (kein „Camping“ am TC)\n" + "• Monumente nicht dauerhaft blockieren (z. B. mit Wänden)\n" + "• Verlassene Basen: nach Serverregel oft erst nach Ablauf der Inaktivitätsfrist – Infos im Discord\n\n" + "PvP-Zonen & Purge\n" + "• In markierten PvP-Zonen gelten andere Risiken – mit Vorsicht reingehen\n" + "• Purge (kurz vor Map-Wipe): intensiveres PvP und Raiding nach Server-Ankündigung – vorbereiten!\n\n" + "Melden\n" + "• Verstöße: /report oder Discord-Ticket mit Beweisen (Clip, Screenshots)\n\n" + "Bei Unklarheiten: vorher im Discord nachfragen – lieber einmal zu viel als ein Ban zu viel.", FontSize = 13, Center = false, IncreaseHeightValue = 0 }, ["pvp"] = new PageConfig { Title = "PvP-Zonen & Risiko", Content = "Nicht überall ist Rust „entspannt“ – auf GameZoneOne gibt es klar markierte PvP-Zonen (z. B. bestimmte Monumente oder Gebiete).\n\n" + "Was du wissen solltest\n" + "• In diesen Zonen darfst du angegriffen werden – auch ohne vorheriges „RPS“\n" + "• Ohne Plan und ohne Ausrüstung: oft schnell tot. Mit Team: deutlich besser\n" + "• Nutze /pvpzones (wenn aktiv), um Bereiche auf der Karte zu sehen\n\n" + "Außerhalb der PvP-Zonen gilt im Normalbetrieb PvE-Charakter – bis zur Purge-Phase vor dem Wipe.\n\n" + "Purge kurz erklärt\n" + "• In den letzten Tagen vor dem Map-Wipe wird angekündigt: stärkeres PvP und Raiding serverweit nach Regelwerk\n" + "• Base, Loot und Meds rechtzeitig sichern oder einplanen\n\n" + "Tipp: Nichts mitnehmen, was du nicht verlieren kannst – besonders in Hotspots.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["wipe"] = new PageConfig { Title = "Wipe & Termine", Content = "Map-Wipe (ca. alle 3 Wochen)\n" + "• Neue Karte, frische Monumente, oft auch Blueprint-Reset – je nach Ankündigung\n" + "• Exakte Daten und Uhrzeiten: immer im Discord (z. B. #ankündigungen oder #wipe)\n" + "• Vor dem Wipe: meist Purge-Phase – siehe Seite „PvP-Zonen & Risiko“\n\n" + "Warum Wipe?\n" + "• Hält den Server performant und fair – alle starten wieder mit ähnlichen Chancen\n\n" + "Was du tun kannst\n" + "• Vor dem Wipe: Ressourcen verbrauchen oder mit Freunden raiden (während Purge erlaubt)\n" + "• Nach dem Wipe: neu planen, neue Routen, neue Nachbarn\n\n" + "Diese Hilfe enthält keine festen Kalenderdaten – die ändern sich. Discord ist die Quelle der Wahrheit.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["report"] = new PageConfig { Title = "Report & Hilfe", Content = "Etwas stimmt nicht – Spieler, Bug oder Verdacht?\n\n" + "Im Spiel\n" + "• /report öffnet das Melde-Interface (Grund, optional Spieler, Text, optional Medien-Links)\n" + "• Beschreibe sachlich: Was? Wo? Wann? Wer (wenn bekannt)?\n" + "• Bei Bugs: kurz reproduzierbar machen („mache X, dann Y, dann passiert Z“)\n" + "• Nach dem Absenden: Trace-ID für das Team; Snapshots unter oxide/data (falls aktiv)\n\n" + "Team (Mods/Admins)\n" + "• /reportadmin — letzte Meldungen (optional /reportadmin 20)\n" + "• Recht: gamezonereportui.admin oder Admin-Flag\n\n" + "Discord\n" + "• Für längere Fälle, Beweise oder Rückfragen: Ticket im Discord\n" + "• Screenshots, Clips, Log-Auschnitte helfen dem Team enorm\n\n" + "Was wir nicht brauchen\n" + "• Flaming im Ticket – kostet nur Zeit\n" + "• Meldungen ohne Inhalt („der ist doof“)\n\n" + "Antwortzeiten können je nach Auslastung variieren – bitte Geduld. Ernstfälle (Cheating, echte Belästigung) haben Priorität.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["performance"] = new PageConfig { Title = "FPS & Stabilität", Content = "Rust ist hungrig – ein paar Einstellungen helfen oft mehr als neuer Rechner.\n\n" + "Grafik (Ingame)\n" + "• Schatten, Wasser, Reflexionen, Gras: runter oder aus\n" + "• Partikel und Effekte reduzieren\n" + "• Auflösung: eine Stufe niedriger kann Wunder wirken\n\n" + "System\n" + "• Vollbild oder randloser Fenster-Modus testen\n" + "• Browser, Launcher, unnötige Apps schließen\n" + "• Treiber (GPU) aktuell halten\n\n" + "Auf dem Server\n" + "• Große PvP-Schlachten und viele Spieler = mehr Last – kurze Ruckler können normal sein\n" + "• Wenn du dauerhaft hohen Ping hast: Verbindung/WLAN prüfen\n\n" + "Tipp: In den Optionen die FPS-Anzeige aktivieren – so siehst du, ob es Grafik oder Netz ist.", FontSize = 14, Center = false, IncreaseHeightValue = 0 } } }, ["commands"] = new CategoryConfig { Title = "Befehle", Entries = new Dictionary { ["teleport"] = new PageConfig { Title = "Teleport & Homes", Content = "Teleport (Spieler zu Spieler)\n" + "• /tpr – Anfrage senden\n" + "• /tpa – Anfrage annehmen\n" + "• /tpc – ablehnen oder abbrechen\n" + "• /tpb – zurück zur letzten Position (wenn verfügbar)\n" + "• /tpinfo – Limits und Cooldowns\n" + "• /tphelp – ausführliche Hilfe zum Plugin\n\n" + "Homes (gespeicherte Punkte)\n" + "• /home set – Home an aktueller Position setzen\n" + "• /home – zum Home teleportieren\n" + "• /home list – alle Homes anzeigen\n" + "• /home remove – Home löschen\n\n" + "Typische Limits (können variieren – /tpinfo prüfen)\n" + "• Standard: z. B. 3 Homes, begrenzte Teleports pro Tag, Cooldown zwischen Sprüngen\n" + "• VIP: oft mehr Homes, kürzerer Cooldown, mehr Teleports – Details im Discord\n\n" + "Fairplay: keine Teleport-Tricks in Kämpfen ausnutzen, wo es Regeln gibt – im Zweifel: Team fragen.", FontSize = 13, Center = false, IncreaseHeightValue = 0 }, ["remove"] = new PageConfig { Title = "Remover & Bauen", Content = "Remover\n" + "• /remove – Menü oder Modus zum Entfernen eigener Bauteile (je nach Plugin)\n" + "• Oft zeitlich begrenzt oder mit Kosten – Hinweise im Chat beachten\n" + "• Nur eigene Bauteile – kein Fremd-Raid im PvE außerhalb der erlaubten Phasen\n\n" + "Bauen (Allgemein)\n" + "• Tool Cupboard (TC) immer zuerst – ohne TC kein Upkeep-Schutz\n" + "• Fundament vor Wänden, Decken für Schutz gegen Wetter\n" + "• Airlocks (Doppel-Türen) reduzieren Offline-Doorcamp-Risiko\n\n" + "Wenn du dich vertan hast: Remover nutzen, statt alles zu sprengen – spart Ressourcen und Nerven.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["clan"] = new PageConfig { Title = "Clan & Allianz", Content = "Clan\n" + "• /clan – Menü oder Hilfe\n" + "• /clan create – Clan gründen\n" + "• /clan invite – einladen\n" + "• /clan join – Beitritt\n" + "• /clan leave – austreten\n" + "• /clan kick – Mitglied entfernen\n" + "• /clan promote | /clan demote – Ränge\n" + "• /clan disband – Clan auflösen (Admin-Rechte im Clan)\n" + "• /cinfo – Informationen\n\n" + "Chat\n" + "• /c – Clan-Chat\n" + "• /a – Allianz-Chat (wenn aktiv)\n" + "• /ally – Allianzen verwalten\n\n" + "Tipp: Tag kurz halten, Namen lesbar – das hilft bei Einladungen und Reports.", FontSize = 13, Center = false, IncreaseHeightValue = 0 }, ["kits"] = new PageConfig { Title = "Kits & Inventar", Content = "Kits\n" + "• /kit – Kit-Menü öffnen\n" + "• /kit starter – Starter-Kit (wenn verfügbar)\n" + "• /kit vip – VIP-Kit (oft 1× pro Tag, nur mit VIP)\n" + "• /kit autokit – automatisches Kit an/aus (falls konfiguriert)\n\n" + "Inventar & Komfort\n" + "• /backpack – Zusatz-Inventar (Slots je nach Server/VIP)\n" + "• /sort oder /qs – Inventar sortieren\n" + "• /fs – Ofensplitter (wenn aktiv)\n\n" + "Tipp: Kits nicht horten, wenn du Platz brauchst – nutze Recycler und Lagerboxen früh.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["economy"] = new PageConfig { Title = "Shop & Wirtschaft", Content = "Überblick\n" + "• /markt oder /shops — Kurzerklärung im Chat (RP-Shop)\n" + "• /s — Server Rewards: Items, Kits, Commands (z. B. Outpost-TP), RP-Transfer; Verkauf & Economics-Tausch bei uns aus\n" + "• /kit — Kits direkt; im /s-Shop dieselben Kit-Namen gegen RP, wo angeboten\n" + "• /daily — tägliche Belohnung (oft nach /link)\n" + "• /refer — Referral-Bonus (wenn aktiv)\n\n" + "RP nutzen\n" + "• RP sammeln (Spielzeit, Events, Discord) und im /s-Shop ausgeben\n" + "• RP an andere senden: Transfer im /s-Menü (wenn aktiv)\n\n" + "Spartipps\n" + "• Vor großen Käufen: Preise im Shop prüfen\n" + "• Team: vorher absprechen, wer was kauft", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["bindings"] = new PageConfig { Title = "Tasten & F1-Konsole", Content = "Eigene Tasten (Bindings)\n" + "• F1-Konsole öffnen, dann z. B.:\n" + " bind k kill\n" + " bind b chat.say /backpack\n" + "• Leerzeichen in Chat-Befehlen: in Anführungszeichen setzen\n" + " Beispiel: bind b \"chat.say /home main\"\n\n" + "Wichtig\n" + "• Zu viele Bindings können unübersichtlich werden – lieber wenige sinnvolle\n" + "• Nach Updates: testen, ob Befehle noch gleich heißen\n\n" + "Tipp: Ein Binding für „schnell Home“ und einer für „Rucksack“ reicht vielen Spielern.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["misc"] = new PageConfig { Title = "Chat, Discord & Sonstiges", Content = "Discord\n" + "• /link – Code anzeigen, im Discord mit Bot verknüpfen\n" + "• /unlink – Verknüpfung trennen (Vorsicht: Features weg)\n\n" + "Melden & Infos\n" + "• /report – Meldeformular\n" + "• /playtime – eigene Spielzeit (wenn aktiv)\n\n" + "Welt & Komfort\n" + "• /voteday – Abstimmung Tag/Nacht (wenn aktiv)\n" + "• /pvpzones – PvP-Zonen auf der Karte (wenn aktiv)\n" + "• /killfeed – Killfeed ein/aus\n" + "• /nv oder /nightvision – Nachtsicht (wenn aktiv)\n\n" + "Wenn ein Befehl „nicht gefunden“ meldet: Plugin kann fehlen oder Befehl umbenannt – kurz im Discord nachfragen.", FontSize = 13, Center = false, IncreaseHeightValue = 0 }, ["farming"] = new PageConfig { Title = "Routen & Tipps", Content = "Sanfter Start\n" + "• Straße entlang: Straßenschilder, kleine Monumente, Recycler\n" + "• Supermarkt, Tankstelle, Oxide-Kisten: Komponenten und Scrap\n" + "• Recycler immer mit nutzen – aus Schrott werden Bauteile\n\n" + "Risiko einteilen\n" + "• Grüne und blaue Schlüssel-Karten: mit Meds, Waffe und Zeitplan\n" + "• PvP-Zonen: nur mit Team oder nach Übung\n\n" + "Mid- bis Endgame\n" + "• Größere Monumente, Heli, Cargo, Oil – mit Base in der Nähe oder Boot\n" + "• Vor Purge: Loot einlagern oder verbrauchen, Pläne mit dem Team klären\n\n" + "Tipp: Stirb nicht mit dem ganzen Scrap in der Tasche – Bank vor dem Run.", FontSize = 14, Center = false, IncreaseHeightValue = 0 } } }, ["community"] = new CategoryConfig { Title = "Community", Entries = new Dictionary { ["discord"] = new PageConfig { Title = "Discord", Content = "Unser Discord ist der Hub für Termine, Events, Regeln-Updates und Support.\n\n" + "Link\n" + "• discord.gg/szBd7ZD5Bq\n\n" + "Im Spiel verbinden\n" + "• /link ausführen – Code merken\n" + "• Im Discord den Anweisungen des Bots folgen (Verknüpfung)\n" + "• Danach: /daily und andere Features oft nutzbar\n\n" + "Was du im Discord findest\n" + "• Wipe- & Purge-Termine\n" + "• Event-Ankündigungen\n" + "• Tickets & Support\n" + "• Stimmung der Community – respektvoll bleiben\n\n" + "Tipp: Rolle lesen, Kanäle nicht spammen – dann hilft dir das Team schneller.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["vip"] = new PageConfig { Title = "VIP", Content = "VIP ist ein Danke an Spieler, die den Server unterstützen – und bringt praktische Vorteile.\n\n" + "Typische Vorteile (kann je nach Paket variieren – Discord ist maßgeblich)\n" + "• Mehr Homes und mehr Teleports / kürzerer Cooldown\n" + "• Größerer Backpack oder zusätzliche Slots\n" + "• Sichtbare Vorteile im Chat (z. B. Farbe oder Tag)\n" + "• Ggf. zusätzliche Kits\n\n" + "Wie bekomme ich VIP?\n" + "• Über das Discord und die dort beschriebenen Schritte (Spende, Paket, Rolle)\n" + "• Kein Pay-to-win im Sinne von unfairen Kampfvorteilen – Server-Philosophie im Discord nachlesen\n\n" + "Fragen?\n" + "• Vor dem Kauf: Infos im Discord\n" + "• Probleme mit Rolle: Support-Ticket, nicht im All-Chat eskalieren", FontSize = 13, Center = false, IncreaseHeightValue = 0 }, ["staff"] = new PageConfig { Title = "Team & Support", Content = "Das Team kümmert sich um Regeln, Technik und Streitschlichtung – aber keiner sitzt 24/7 nur im Spiel.\n\n" + "Erreichbarkeit\n" + "• Am zuverlässigsten: Discord (Tickets, Kanäle)\n" + "• Eiliges im Spiel: /report (mit Beweisen)\n\n" + "Was wir gut können\n" + "• Sachliche Meldungen mit Details\n" + "• Bugs mit Reproduktion\n" + "• Verstöße mit Beweisen\n\n" + "Was uns Zeit kostet\n" + "• „Admin kommt mal her“ ohne Grund\n" + "• Diskussionen im Chat statt Ticket\n\n" + "Gegen das Team selbst?\n" + "• Ruhig, sachlich, mit Beweisen – es gibt Eskalationswege im Discord\n\n" + "Danke, dass du fair bleibst – das macht den Server für alle besser.", FontSize = 14, Center = false, IncreaseHeightValue = 0 } } }, ["events"] = new CategoryConfig { Title = "Events", Entries = new Dictionary { ["overview"] = new PageConfig { Title = "Übersicht", Content = "Auf GameZoneOne laufen verschiedene Events – automatisch (Server) oder manuell (Team).\n\n" + "Typische Beispiele\n" + "• Heli / Bradley / Kisten-Events\n" + "• Cargo-Schiff und Ölfelder\n" + "• Airdrops (stark frequentiert, PvP-Risiko)\n" + "• Purge-Phase vor dem Map-Wipe\n\n" + "Wo Infos?\n" + "• Chat-Ankündigungen\n" + "• Discord (#events, #ankündigungen)\n\n" + "Tipp: Trag dich nicht mit vollem Inventar in jedes Event – besonders bei Airdrop und Heli.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["purge"] = new PageConfig { Title = "Purge", Content = "Die Purge ist die heiße Phase vor dem Map-Wipe.\n\n" + "Was sich ändert\n" + "• Serverweit gelten die angekündigten PvP- und Raid-Regeln – intensiver als im normalen PvE-Alltag\n" + "• Basen, Allianzen und Loot sind stärker bedroht\n\n" + "Was du tun solltest\n" + "• Vorräte einplanen: Meds, Munition, Sprengstoff nach Regelwerk\n" + "• Mit Team sprechen: wer verteidigt, wer farmt, wer raided\n" + "• Nicht alles auf eine Karte setzen – Backup-Depot kann retten\n\n" + "Wann genau?\n" + "• Immer im Discord ankündigt – keine festen Zeiten in dieser Hilfe\n\n" + "Kein Überraschungs-Purge: achte auf Discord und Chat.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["airdrop"] = new PageConfig { Title = "Airdrop", Content = "Airdrops ziehen Spieler an wie ein Licht – erwarte Kampf.\n\n" + "Vorbereitung\n" + "• Waffe, Meds, ggf. Sprengstoff – je nach Situation\n" + "• Position: Deckung, Höhe, Fluchtweg\n" + "• Team: einer schaut Umgebung, einer lootet\n\n" + "Verhalten\n" + "• Nicht im offenen Feld stehen bleiben\n" + "• Auf Schüsse und Schritte hören\n" + "• Lieber mitziehen als unnötig sterben\n\n" + "Nach dem Loot: schnell weg oder sichern – Gefecht geht oft weiter als die Kiste.", FontSize = 14, Center = false, IncreaseHeightValue = 0 }, ["cargoship"] = new PageConfig { Title = "Cargo & Oil", Content = "Cargo-Schiff\n" + "• Hoher Loot, viele Spieler, Chaos möglich\n" + "• Boot, Meds, Wiederbelebung / Bett in der Nähe planen\n" + "• Nicht alleine gehen, wenn du unsicher bist\n\n" + "Oil Rig / große Wasser-Events\n" + "• Fernkampf, Sniper, Boote – oft Endgame-Niveau\n" + "• Wissenschaftler und Spieler gleichermaßen gefährlich\n\n" + "Tipp: Wenn du nur farmen willst, meide die Hotspots zur Rush-Hour – oder geh mit klarer Absprache im Team.", FontSize = 14, Center = false, IncreaseHeightValue = 0 } } }, }; } private class CategoryConfig { [JsonProperty("Title")] public string Title = "Kategorie"; [JsonProperty("Entries")] public Dictionary Entries = new Dictionary(); } private class PageConfig { [JsonProperty("Title")] public string Title = "Seite"; [JsonProperty("Content")] public string Content = string.Empty; [JsonProperty("Font Size")] public int FontSize = 15; [JsonProperty("Center")] public bool Center; [JsonProperty("Increase Height Value")] public int IncreaseHeightValue; [JsonProperty("Admin Only")] public bool AdminOnly; } protected override void LoadDefaultConfig() { _config = new PluginConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); _config = Config.ReadObject(); if (_config == null) { PrintWarning("Config war leer/ungueltig, Standard wird neu erstellt."); LoadDefaultConfig(); } EnsureConfigDefaults(); } protected override void SaveConfig() => Config.WriteObject(_config, true); private string T(string key, string userId = null, params object[] args) { var msg = lang.GetMessage(key, this, userId); return args.Length > 0 ? string.Format(msg, args) : msg; } protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["NoCategories"] = "No categories configured — please check HelpCenterUI.json.", ["NoPageVisible"] = "No help page visible — check permissions or admin pages.", ["PageNotFound"] = "Help editor: page not found or not visible.", ["AdminOnly"] = "Only server admins may edit this page.", ["PageInvalid"] = "Page is no longer valid.", ["HelpSaved"] = "Help saved: {0} / {1}", ["ChatHeader"] = "----- Help: {0} -----", ["ConfirmDelPage"] = "To delete this page press \"DELETE PAGE\" again (within {0} s).", ["ConfirmDelCat"] = "To delete this category press \"DELETE CATEGORY\" again (within {0} s).", ["OpenHelpFirst"] = "Open /help first or use /helpedit newpage .", }, this); } private void Init() { permission.RegisterPermission(PermAdmin, this); permission.RegisterPermission(PermEdit, this); AddCovalenceCommand("helpreload", nameof(CmdHelpReload)); AddCovalenceCommand("helpedit", nameof(CmdHelpEdit)); RegisterConfiguredCommands(); } private void OnServerInitialized() { RebuildDynamicCaches(); EnsureHelpBackgroundImageRegistered(); if (!string.IsNullOrWhiteSpace(_config?.BackgroundImageUrl) && ImageLibrary == null) PrintWarning( "Background Image Url ist gesetzt, aber ImageLibrary fehlt. Installiere ImageLibrary für zuverlässige Hintergrundbilder (Png-Cache). Direkte URLs zeigt der Client oft nicht an."); } private void OnPluginLoaded(Plugin plugin) { if (plugin?.Name != "ImageLibrary") return; _backgroundImageRegisteredForUrl = null; EnsureHelpBackgroundImageRegistered(); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; _pendingDeletes.Remove(player.userID); } private void OnPlayerConnected(BasePlayer player) { if (player == null || player.IsNpc) return; if (!(_config?.OpenHelpOnConnect ?? true)) return; var delay = Mathf.Clamp(_config.OpenHelpDelaySeconds ?? 2.5f, 0.5f, 30f); var userId = player.userID; timer.Once(delay, () => { var p = BasePlayer.FindByID(userId); if (p == null || !p.IsConnected || p.IsNpc) return; OpenUi(p, null, null); }); } private void EnsureConfigDefaults() { var defaults = new PluginConfig(); bool changed = false; if (_config.Commands == null) { _config.Commands = new Dictionary(); changed = true; } foreach (var kv in defaults.Commands) { if (!_config.Commands.ContainsKey(kv.Key)) { _config.Commands[kv.Key] = kv.Value; changed = true; } } if (_config.BackgroundImageUrl == null) { _config.BackgroundImageUrl = defaults.BackgroundImageUrl; changed = true; } if (_config.OpenHelpOnConnect == null) { _config.OpenHelpOnConnect = true; changed = true; } if (_config.OpenHelpDelaySeconds == null) { _config.OpenHelpDelaySeconds = 2.5f; changed = true; } if (_config.MainCategoryOrder == null || _config.MainCategoryOrder.Count == 0) { _config.MainCategoryOrder = new List(defaults.MainCategoryOrder); changed = true; } else { var normalized = new List(); foreach (var raw in _config.MainCategoryOrder) { string key = (raw ?? string.Empty).Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(key) || normalized.Contains(key)) continue; normalized.Add(key); } foreach (var key in defaults.MainCategoryOrder) { if (!normalized.Contains(key)) normalized.Add(key); } if (normalized.Count > MaxMainCategories) normalized = normalized.Take(MaxMainCategories).ToList(); if (_config.MainCategoryOrder.Count != normalized.Count || _config.MainCategoryOrder.Where((t, i) => i < normalized.Count && t != normalized[i]).Any()) { _config.MainCategoryOrder = normalized; changed = true; } } if (_config.MainCategoryOrder != null && _config.MainCategoryOrder.Any(x => { var k = (x ?? string.Empty).Trim().ToLowerInvariant(); return k == "ranks" || k == "wiki"; })) { _config.MainCategoryOrder = new List(defaults.MainCategoryOrder); changed = true; } if (_config.Pages == null) { _config.Pages = new Dictionary(); changed = true; } foreach (var cat in defaults.Pages) { if (!_config.Pages.TryGetValue(cat.Key, out var existingCat) || existingCat == null) { _config.Pages[cat.Key] = cat.Value; changed = true; continue; } if (string.IsNullOrWhiteSpace(existingCat.Title)) { existingCat.Title = cat.Value.Title; changed = true; } if (existingCat.Entries == null) { existingCat.Entries = new Dictionary(); changed = true; } foreach (var page in cat.Value.Entries) { if (!existingCat.Entries.ContainsKey(page.Key)) { existingCat.Entries[page.Key] = page.Value; changed = true; } } // Migration für bestehende Configs. foreach (var existingPage in existingCat.Entries) { if (existingPage.Value == null) continue; if (cat.Key == "commands" && existingPage.Key == "allebefehle" && string.Equals(existingPage.Value.Content?.Trim(), "{{AUTO_COMMANDS}}", StringComparison.OrdinalIgnoreCase)) { existingPage.Value.Content = "{{AUTO_COMMANDS_PUBLIC}}"; if (string.Equals(existingPage.Value.Title, "Alle Befehle (Auto)", StringComparison.OrdinalIgnoreCase)) existingPage.Value.Title = "Spielerbefehle (Auto)"; changed = true; } if (cat.Key == "commands" && existingPage.Key == "adminbefehle" && !existingPage.Value.AdminOnly) { existingPage.Value.AdminOnly = true; changed = true; } } } StripLegacyAutoCommandPages(ref changed); if (changed) SaveConfig(); } private void StripLegacyAutoCommandPages(ref bool changed) { if (_config?.Pages == null) return; foreach (var catKvp in _config.Pages.ToList()) { var entries = catKvp.Value?.Entries; if (entries == null) continue; if (catKvp.Key == "commands") { if (entries.Remove("allebefehle")) changed = true; if (entries.Remove("adminbefehle")) changed = true; } if (catKvp.Key == "ranks" && entries.Remove("teamliste")) changed = true; } } private void Unload() { foreach (var player in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(player, UiNameMain); CuiHelper.DestroyUi(player, UiNameBg); CuiHelper.DestroyUi(player, UiNameEdit); } _helpBgActive.Clear(); _uiStates.Clear(); _editDrafts.Clear(); _pendingDeletes.Clear(); _backgroundImageRegisteredForUrl = null; } private void RegisterConfiguredCommands() { if (_config.Commands == null || _config.Commands.Count == 0) return; foreach (var cmd in _config.Commands.Keys.ToList()) { if (string.IsNullOrWhiteSpace(cmd)) continue; AddCovalenceCommand(cmd.Trim().ToLowerInvariant(), nameof(CmdConfiguredHelp)); } } private void CmdConfiguredHelp(IPlayer iPlayer, string command, string[] args) { var player = iPlayer.Object as BasePlayer; if (player == null) return; var cmd = (command ?? string.Empty).Trim().ToLowerInvariant(); string target = string.Empty; if (_config.Commands != null && _config.Commands.TryGetValue(cmd, out var configured)) target = configured ?? string.Empty; // Sonderfall /helppage if (cmd == "helppage" && args != null && args.Length > 0) { string cat = args[0].ToLowerInvariant(); string page = args.Length > 1 ? args[1].ToLowerInvariant() : string.Empty; OpenUi(player, cat, page); return; } if (string.IsNullOrWhiteSpace(target)) { OpenUi(player, null, null); return; } SplitTarget(target, out var category, out var pageKey); OpenUi(player, category, pageKey); } private void CmdHelpReload(IPlayer iPlayer, string command, string[] args) { if (iPlayer == null) return; bool allowed = iPlayer.HasPermission(PermAdmin) || iPlayer.IsAdmin; var basePlayer = iPlayer.Object as BasePlayer; if (!allowed && basePlayer != null) allowed = basePlayer.IsAdmin || permission.UserHasPermission(basePlayer.UserIDString, PermAdmin); if (!allowed) { iPlayer.Reply("Keine Berechtigung."); return; } try { LoadConfig(); _backgroundImageRegisteredForUrl = null; RebuildDynamicCaches(); EnsureHelpBackgroundImageRegistered(); iPlayer.Reply("HelpCenterUI wurde neu geladen und aktualisiert."); } catch (Exception ex) { iPlayer.Reply("Fehler beim Reload: " + ex.Message); } } [ConsoleCommand("hcui.close")] private void CmdUiClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CloseUi(player); } [ConsoleCommand("hcui.main")] private void CmdUiMain(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || arg.Args == null || arg.Args.Length < 1) return; string category = arg.Args[0].ToLowerInvariant(); OpenUi(player, category, null); } [ConsoleCommand("hcui.page")] private void CmdUiPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || arg.Args == null || arg.Args.Length < 2) return; string category = arg.Args[0].ToLowerInvariant(); string page = arg.Args[1].ToLowerInvariant(); OpenUi(player, category, page); } [ConsoleCommand("hcui.part")] private void CmdUiPart(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || arg.Args == null || arg.Args.Length < 1) return; if (!_uiStates.TryGetValue(player.userID, out var state)) return; if (!int.TryParse(arg.Args[0], out int delta)) return; state.ContentPart += delta; if (state.ContentPart < 0) state.ContentPart = 0; OpenUi(player, state.CategoryKey, state.PageKey, false); } [ConsoleCommand("hcui.subpart")] private void CmdUiSubPart(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || arg.Args == null || arg.Args.Length < 1) return; if (!_uiStates.TryGetValue(player.userID, out var state)) return; if (!int.TryParse(arg.Args[0], out int delta)) return; state.SubCategoryPage += delta; if (state.SubCategoryPage < 0) state.SubCategoryPage = 0; OpenUi(player, state.CategoryKey, state.PageKey, false); } [ConsoleCommand("hcui.print")] private void CmdUiPrint(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; if (!_uiStates.TryGetValue(player.userID, out var state)) return; if (!TryGetPage(state.CategoryKey, state.PageKey, out var page)) return; var rendered = RenderPageContent(page.Content); var lines = SplitLines(rendered); player.ChatMessage(T("ChatHeader", player.UserIDString, page.Title)); foreach (var line in lines) { if (!string.IsNullOrWhiteSpace(line)) player.ChatMessage(line); } } private void EnsureHelpBackgroundImageRegistered() { var url = (_config?.BackgroundImageUrl ?? string.Empty).Trim(); if (string.IsNullOrEmpty(url) || ImageLibrary == null) return; if (string.Equals(_backgroundImageRegisteredForUrl, url, StringComparison.Ordinal)) return; ImageLibrary.Call("AddImage", url, ImageLibraryBackgroundName, 0UL); _backgroundImageRegisteredForUrl = url; } private void AddFullscreenBackgroundLayers(CuiElementContainer ui, string bgParent) { var url = (_config?.BackgroundImageUrl ?? string.Empty).Trim(); if (!string.IsNullOrEmpty(url)) { var imgAlpha = Mathf.Clamp01(_config.BackgroundImageAlpha <= 0f ? 1f : _config.BackgroundImageAlpha); EnsureHelpBackgroundImageRegistered(); var raw = new CuiRawImageComponent { Sprite = FullscreenRawImageSprite, Color = $"1 1 1 {imgAlpha:F2}" }; var usePng = false; if (ImageLibrary != null && ImageLibrary.Call("HasImage", ImageLibraryBackgroundName, 0UL)) { var pngId = ImageLibrary.Call("GetImage", ImageLibraryBackgroundName, 0UL); if (!string.IsNullOrEmpty(pngId)) { raw.Png = pngId; usePng = true; } } if (!usePng) raw.Url = url; ui.Add(new CuiElement { Name = CuiHelper.GetGuid(), Parent = bgParent, Components = { raw, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" } } }); } var darken = _config.BackgroundGlobalDarken; if (darken <= 0.001f) darken = 0.45f; darken = Mathf.Clamp(darken, 0f, 0.88f); ui.Add(new CuiPanel { Image = { Color = $"0 0 0 {darken:F2}" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, bgParent); } private void EnsureHelpBackground(BasePlayer player) { if (_config == null || !_config.FullscreenLayout || player == null) return; if (_helpBgActive.Contains(player.userID)) return; var ui = new CuiElementContainer(); ui.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = false }, "Overlay", UiNameBg); AddFullscreenBackgroundLayers(ui, UiNameBg); CuiHelper.AddUi(player, ui); _helpBgActive.Add(player.userID); } private void DestroyHelpBg(BasePlayer player) { if (player == null) return; if (!_helpBgActive.Contains(player.userID)) return; CuiHelper.DestroyUi(player, UiNameBg); _helpBgActive.Remove(player.userID); } private bool HasEditPermission(BasePlayer player) { if (player == null) return false; if (player.IsAdmin) return true; return permission.UserHasPermission(player.UserIDString, PermEdit); } private void OpenUi(BasePlayer player, string categoryKey, string pageKey, bool resetPart = true) { var mainKeys = GetMainCategoryKeys(); if (mainKeys.Count == 0) { player.ChatMessage(T("NoCategories", player.UserIDString)); return; } categoryKey = ResolveCategoryKey(categoryKey, mainKeys); pageKey = ResolvePageKey(categoryKey, pageKey, player); if (!_uiStates.TryGetValue(player.userID, out var state)) _uiStates[player.userID] = state = new PlayerUiState(); bool changedPage = state.PageKey != pageKey || state.CategoryKey != categoryKey; bool changedCategory = state.CategoryKey != categoryKey; state.CategoryKey = categoryKey; state.PageKey = pageKey; if (resetPart || changedPage) state.ContentPart = 0; if (changedCategory) state.SubCategoryPage = 0; if (!TryGetPage(categoryKey, pageKey, out var page) || !IsPageVisibleToPlayer(page, player)) return; var renderedContent = RenderPageContent(page.Content); var parts = Paginate(renderedContent, ContentLinesPerPart); if (state.ContentPart >= parts.Count) state.ContentPart = parts.Count - 1; if (state.ContentPart < 0) state.ContentPart = 0; CuiHelper.DestroyUi(player, UiNameMain); CuiHelper.DestroyUi(player, UiNameEdit); var fullscreen = _config != null && _config.FullscreenLayout; if (fullscreen) EnsureHelpBackground(player); else DestroyHelpBg(player); var ui = new CuiElementContainer(); if (fullscreen) { ui.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiNameMain); } else { ui.Add(new CuiPanel { Image = { Color = "0.02 0.02 0.02 0.86" }, RectTransform = { AnchorMin = "0.03 0.05", AnchorMax = "0.97 0.95" }, CursorEnabled = true }, "Overlay", UiNameMain); } ui.Add(new CuiPanel { Image = { Color = "0.02 0.02 0.04 0.96" }, RectTransform = { AnchorMin = "0.01 0.87", AnchorMax = "0.99 0.99" } }, UiNameMain); ui.Add(new CuiLabel { Text = { Text = "GAMEZONEONE · HILFE", FontSize = 28, Align = TextAnchor.MiddleLeft, Color = "1 0.82 0.45 1" }, RectTransform = { AnchorMin = "0.025 0.925", AnchorMax = "0.72 0.985" } }, UiNameMain); if (HasEditPermission(player)) { ui.Add(new CuiButton { Button = { Color = "0.22 0.35 0.55 1", Command = "hcui.edit.open" }, Text = { Text = "BEARBEITEN", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.78 0.93", AnchorMax = "0.958 0.982" } }, UiNameMain); } ui.Add(new CuiButton { Button = { Color = "0.75 0.23 0.18 1", Command = "hcui.close" }, Text = { Text = "✕", FontSize = 22, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.965 0.93", AnchorMax = "0.992 0.982" } }, UiNameMain); ui.Add(new CuiPanel { Image = { Color = "0.06 0.06 0.08 0.90" }, RectTransform = { AnchorMin = "0.018 0.12", AnchorMax = "0.305 0.86" } }, UiNameMain); ui.Add(new CuiPanel { Image = { Color = "0.06 0.06 0.08 0.90" }, RectTransform = { AnchorMin = "0.315 0.12", AnchorMax = "0.982 0.86" } }, UiNameMain); BuildMainTabs(ui, mainKeys, categoryKey); BuildSubPages(ui, categoryKey, pageKey, state, player); BuildContent(ui, categoryKey, page, parts[state.ContentPart], state.ContentPart, parts.Count); CuiHelper.AddUi(player, ui); } private void BuildMainTabs(CuiElementContainer ui, List mainKeys, string activeCategory) { float startX = 0.03f; float gap = 0.006f; float totalWidth = 0.94f - startX; float width = (totalWidth - ((mainKeys.Count - 1) * gap)) / Math.Max(1, mainKeys.Count); for (int i = 0; i < mainKeys.Count; i++) { string key = mainKeys[i]; string title = GetCategoryTitle(key); bool active = key == activeCategory; float min = startX + i * (width + gap); float max = min + width; ui.Add(new CuiButton { Button = { Color = active ? "0.55 0.32 0.08 1" : "0.10 0.10 0.10 0.98", Command = "hcui.main " + key }, Text = { Text = title.ToUpperInvariant(), FontSize = 16, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = min.ToString("0.000") + " 0.80", AnchorMax = max.ToString("0.000") + " 0.885" } }, UiNameMain); } } private void BuildSubPages(CuiElementContainer ui, string categoryKey, string activePage, PlayerUiState state, BasePlayer player) { ui.Add(new CuiLabel { Text = { Text = "THEMEN", FontSize = 15, Align = TextAnchor.MiddleLeft, Color = "0.95 0.95 0.95 1" }, RectTransform = { AnchorMin = "0.035 0.74", AnchorMax = "0.29 0.79" } }, UiNameMain); var pages = GetPageKeys(categoryKey, player); if (pages.Count == 0) return; int maxPage = (int)Math.Ceiling(pages.Count / (float)SubCategoriesPerPage) - 1; if (maxPage < 0) maxPage = 0; if (state.SubCategoryPage > maxPage) state.SubCategoryPage = maxPage; int start = state.SubCategoryPage * SubCategoriesPerPage; int end = Math.Min(start + SubCategoriesPerPage, pages.Count); float yTop = 0.72f; float rowHeight = 0.046f; for (int i = start; i < end; i++) { string pageKey = pages[i]; var page = _config.Pages[categoryKey].Entries[pageKey]; bool active = pageKey == activePage; int row = i - start; float max = yTop - row * rowHeight; float min = max - (rowHeight - 0.004f); if (min < 0.14f) break; ui.Add(new CuiButton { Button = { Color = active ? "0.55 0.32 0.08 1" : "0.07 0.07 0.07 0.95", Command = $"hcui.page {categoryKey} {pageKey}" }, Text = { Text = page.Title.ToUpperInvariant(), FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" }, RectTransform = { AnchorMin = $"0.035 {min:0.000}", AnchorMax = $"0.295 {max:0.000}" } }, UiNameMain); } if (maxPage > 0) { ui.Add(new CuiLabel { Text = { Text = $"Seite {state.SubCategoryPage + 1}/{maxPage + 1}", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "0.9 0.9 0.9 1" }, RectTransform = { AnchorMin = "0.11 0.12", AnchorMax = "0.23 0.15" } }, UiNameMain); ui.Add(new CuiButton { Button = { Color = "0.16 0.16 0.16 0.95", Command = "hcui.subpart -1" }, Text = { Text = "<", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.04 0.12", AnchorMax = "0.09 0.15" } }, UiNameMain); ui.Add(new CuiButton { Button = { Color = "0.16 0.16 0.16 0.95", Command = "hcui.subpart 1" }, Text = { Text = ">", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.245 0.12", AnchorMax = "0.295 0.15" } }, UiNameMain); } } private void BuildContent(CuiElementContainer ui, string categoryKey, PageConfig page, string partText, int partIndex, int totalParts) { ui.Add(new CuiLabel { Text = { Text = GetCategoryTitle(categoryKey).ToUpperInvariant(), FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "0.82 0.84 0.88 1" }, RectTransform = { AnchorMin = "0.33 0.752", AnchorMax = "0.96 0.796" } }, UiNameMain); ui.Add(new CuiLabel { Text = { Text = page.Title.ToUpperInvariant(), FontSize = 16, Align = TextAnchor.MiddleLeft, Color = "1 0.88 0.45 1" }, RectTransform = { AnchorMin = "0.33 0.704", AnchorMax = "0.96 0.746" } }, UiNameMain); float minY = MainContentMinY; float maxY = MainContentMaxY; if (page.IncreaseHeightValue != 0) { float expand = Mathf.Clamp(page.IncreaseHeightValue / 1000f, -0.08f, 0.12f); maxY = Mathf.Clamp(maxY + expand, 0.58f, MainContentMaxYHardCap); } ui.Add(new CuiPanel { Image = { Color = "0.05 0.05 0.07 0.93" }, RectTransform = { AnchorMin = $"0.327 {minY:0.000}", AnchorMax = $"0.965 {maxY:0.000}" } }, UiNameMain, UiNameMain + ".Content"); ui.Add(new CuiLabel { Text = { Text = partText, Font = "robotocondensed-regular.ttf", FontSize = Mathf.Clamp(page.FontSize, 12, 24), Align = page.Center ? TextAnchor.UpperCenter : TextAnchor.UpperLeft, Color = "0.95 0.95 0.95 1", VerticalOverflow = VerticalWrapMode.Overflow }, RectTransform = { AnchorMin = "0.01 0.03", AnchorMax = "0.99 0.97" } }, UiNameMain + ".Content"); if (totalParts > 1) { ui.Add(new CuiLabel { Text = { Text = $"Seite {partIndex + 1}/{totalParts}", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "0.9 0.9 0.9 1" }, RectTransform = { AnchorMin = "0.57 0.055", AnchorMax = "0.70 0.085" } }, UiNameMain); ui.Add(new CuiButton { Button = { Color = "0.20 0.20 0.20 0.95", Command = "hcui.part -1" }, Text = { Text = "<", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.52 0.055", AnchorMax = "0.56 0.085" } }, UiNameMain); ui.Add(new CuiButton { Button = { Color = "0.20 0.20 0.20 0.95", Command = "hcui.part 1" }, Text = { Text = ">", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.71 0.055", AnchorMax = "0.75 0.085" } }, UiNameMain); } ui.Add(new CuiButton { Button = { Color = "0.18 0.40 0.62 1", Command = "hcui.print" }, Text = { Text = "IN CHAT ANZEIGEN", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.79 0.055", AnchorMax = "0.96 0.085" } }, UiNameMain); } private void CloseUi(BasePlayer player) { CuiHelper.DestroyUi(player, UiNameMain); CuiHelper.DestroyUi(player, UiNameEdit); DestroyHelpBg(player); _uiStates.Remove(player.userID); _editDrafts.Remove(player.userID); } private void CmdHelpEdit(IPlayer iPlayer, string command, string[] args) { var player = iPlayer?.Object as BasePlayer; if (player == null) return; if (!HasEditPermission(player)) { iPlayer.Reply("Keine Berechtigung. Benötigt: helpcenterui.edit (oder Admin)."); return; } if (args != null && args.Length >= 1) { var sub = (args[0] ?? string.Empty).Trim().ToLowerInvariant(); if (sub == "newpage" && args.Length >= 3) { var title = args.Length > 3 ? string.Join(" ", args.Skip(3)) : null; TryCreateNewPage(player, args[1].Trim().ToLowerInvariant(), args[2].Trim().ToLowerInvariant(), title, iPlayer); return; } if (sub == "newcategory" && args.Length >= 2) { var title = args.Length > 2 ? string.Join(" ", args.Skip(2)) : null; TryCreateNewCategory(player, args[1].Trim().ToLowerInvariant(), title, iPlayer); return; } if (sub == "delpage" && args.Length >= 3) { TryDeletePage(player, args[1].Trim().ToLowerInvariant(), args[2].Trim().ToLowerInvariant(), iPlayer); return; } if (sub == "delcategory" && args.Length >= 2) { TryDeleteCategory(player, args[1].Trim().ToLowerInvariant(), iPlayer); return; } } string cat = null; string page = null; if (args != null && args.Length >= 2) { cat = args[0].Trim().ToLowerInvariant(); page = args[1].Trim().ToLowerInvariant(); } else if (_uiStates.TryGetValue(player.userID, out var st)) { cat = st.CategoryKey; page = st.PageKey; } if (string.IsNullOrEmpty(cat) || string.IsNullOrEmpty(page)) { var mk = GetMainCategoryKeys(); if (mk.Count == 0) { iPlayer.Reply("Keine Kategorien."); return; } cat = mk[0]; var pk = GetPageKeys(cat, player); if (pk.Count == 0) { iPlayer.Reply("Keine Seiten in dieser Kategorie."); return; } page = pk[0]; } OpenEditUi(player, cat, page); } [ConsoleCommand("hcui.edit.open")] private void CmdEditOpen(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; string cat = null; string page = null; if (_uiStates.TryGetValue(player.userID, out var st)) { cat = st.CategoryKey; page = st.PageKey; } if (string.IsNullOrEmpty(cat) || string.IsNullOrEmpty(page)) { var mk = GetMainCategoryKeys(); if (mk.Count == 0) return; cat = mk[0]; var pk = GetPageKeys(cat, player); if (pk.Count == 0) return; page = pk[0]; } OpenEditUi(player, cat, page); } private void OpenEditUi(BasePlayer player, string categoryKey, string pageKey) { if (!HasEditPermission(player)) return; if (!TryGetPage(categoryKey, pageKey, out var pageCfg) || !IsPageVisibleToPlayer(pageCfg, player)) { player.ChatMessage(T("PageNotFound", player.UserIDString)); return; } if (pageCfg.AdminOnly && !player.IsAdmin) { player.ChatMessage(T("AdminOnly", player.UserIDString)); return; } CuiHelper.DestroyUi(player, UiNameMain); CuiHelper.DestroyUi(player, UiNameEdit); var draft = new EditDraft { CategoryKey = categoryKey, PageKey = pageKey, Title = pageCfg.Title ?? string.Empty, Content = pageCfg.Content ?? string.Empty }; _editDrafts[player.userID] = draft; var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = "0.04 0.04 0.06 0.97" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiNameEdit); c.Add(new CuiLabel { Text = { Text = "HILFE-EDITOR · " + categoryKey + " / " + pageKey, FontSize = 22, Align = TextAnchor.MiddleLeft, Color = "1 0.85 0.45 1" }, RectTransform = { AnchorMin = "0.04 0.88", AnchorMax = "0.62 0.96" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.18 0.42 0.32 1", Command = "hcui.edit.newpage" }, Text = { Text = "Neue Seite", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.63 0.89", AnchorMax = "0.738 0.955" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.28 0.32 0.48 1", Command = "hcui.edit.newcategory" }, Text = { Text = "Neue Kategorie", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.744 0.89", AnchorMax = "0.872 0.955" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.75 0.23 0.18 1", Command = "hcui.edit.cancel" }, Text = { Text = "✕", FontSize = 20, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.878 0.89", AnchorMax = "0.985 0.965" } }, UiNameEdit); c.Add(new CuiLabel { Text = { Text = "Titel (Tab-Name in der Liste)", FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "0.85 0.88 0.95 1" }, RectTransform = { AnchorMin = "0.04 0.80", AnchorMax = "0.55 0.845" } }, UiNameEdit); c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.12 1" }, RectTransform = { AnchorMin = "0.04 0.735", AnchorMax = "0.96 0.795" } }, UiNameEdit, UiNameEdit + ".TitleBg"); c.Add(new CuiElement { Name = "EditTitleField", Parent = UiNameEdit + ".TitleBg", Components = { new CuiInputFieldComponent { Align = TextAnchor.MiddleLeft, CharsLimit = 120, Command = "hcui.edit.settitle ", FontSize = 16, IsPassword = false, Text = draft.Title, NeedsKeyboard = true, Color = "0.95 0.95 0.95 1" }, new CuiRectTransformComponent { AnchorMin = "0.02 0.1", AnchorMax = "0.98 0.9" } } }); c.Add(new CuiLabel { Text = { Text = "Inhalt (mehrzeilig; Zeilenumbrüche bleiben erhalten)", FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "0.85 0.88 0.95 1" }, RectTransform = { AnchorMin = "0.04 0.665", AnchorMax = "0.92 0.715" } }, UiNameEdit); c.Add(new CuiPanel { Image = { Color = "0.08 0.08 0.1 1" }, RectTransform = { AnchorMin = "0.04 0.175", AnchorMax = "0.96 0.655" } }, UiNameEdit, UiNameEdit + ".BodyBg"); c.Add(new CuiElement { Name = "EditContentField", Parent = UiNameEdit + ".BodyBg", Components = { new CuiInputFieldComponent { Align = TextAnchor.UpperLeft, CharsLimit = 8000, Command = "hcui.edit.setcontent ", Font = "robotocondensed-regular.ttf", FontSize = 14, LineType = InputField.LineType.MultiLineNewline, IsPassword = false, Text = draft.Content, NeedsKeyboard = true, Color = "0.92 0.92 0.92 1" }, new CuiRectTransformComponent { AnchorMin = "0.015 0.03", AnchorMax = "0.985 0.97" } } }); c.Add(new CuiLabel { Text = { Text = "Löschen: Button zweimal innerhalb von " + (int)PendingDeleteConfirmSeconds + " s drücken · Chat: /helpedit delpage · /helpedit delcategory ", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.7 0.55 0.55 1" }, RectTransform = { AnchorMin = "0.04 0.098", AnchorMax = "0.96 0.128" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.55 0.28 0.18 1", Command = "hcui.edit.delpage" }, Text = { Text = "SEITE LÖSCHEN", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.04 0.03", AnchorMax = "0.30 0.088" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.42 0.14 0.14 1", Command = "hcui.edit.delcategory" }, Text = { Text = "KATEGORIE LÖSCHEN", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.31 0.03", AnchorMax = "0.58 0.088" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.2 0.55 0.32 1", Command = "hcui.edit.save" }, Text = { Text = "SPEICHERN → HelpCenterUI.json", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.59 0.03", AnchorMax = "0.82 0.088" } }, UiNameEdit); c.Add(new CuiButton { Button = { Color = "0.25 0.25 0.28 1", Command = "hcui.edit.cancel" }, Text = { Text = "ABBRECHEN", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.83 0.03", AnchorMax = "0.97 0.088" } }, UiNameEdit); c.Add(new CuiLabel { Text = { Text = "Änderungen gelten sofort; helpreload nur nötig, wenn du die JSON extern geändert hast.", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.65 0.7 0.8 1" }, RectTransform = { AnchorMin = "0.04 0.128", AnchorMax = "0.96 0.168" } }, UiNameEdit); CuiHelper.AddUi(player, c); } [ConsoleCommand("hcui.edit.settitle")] private void CmdEditSetTitle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (!_editDrafts.TryGetValue(player.userID, out var d)) return; d.Title = JoinConsoleArgs(arg); } [ConsoleCommand("hcui.edit.setcontent")] private void CmdEditSetContent(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (!_editDrafts.TryGetValue(player.userID, out var d)) return; d.Content = JoinConsoleArgsMultiline(arg); } /// /// Einzeilig (Titel): mehrere Args = Leerzeichen dazwischen. /// private static string JoinConsoleArgs(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length == 0) return string.Empty; if (arg.Args.Length == 1) return arg.Args[0]; return string.Join(" ", arg.Args); } /// /// Mehrzeilig: ein Arg oft kompletter Text mit \n; sonst eine Zeile pro Arg vom Client. /// private static string JoinConsoleArgsMultiline(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length == 0) return string.Empty; if (arg.Args.Length == 1) return arg.Args[0]; return string.Join("\n", arg.Args); } private static bool IsValidHelpSlug(string raw) { if (string.IsNullOrEmpty(raw)) return false; var s = raw.Trim().ToLowerInvariant(); if (s.Length < 2 || s.Length > 48) return false; foreach (char c in s) { if (c >= 'a' && c <= 'z') continue; if (c >= '0' && c <= '9') continue; if (c == '-' || c == '_') continue; return false; } return true; } private string GenerateUniquePageKey(string categoryKey) { if (_config.Pages == null || !_config.Pages.TryGetValue(categoryKey, out var cat) || cat.Entries == null) return "seite_" + UnityEngine.Random.Range(10000, 99999); for (int i = 0; i < 64; i++) { var candidate = "seite_" + UnityEngine.Random.Range(10000, 99999); if (!cat.Entries.Keys.Any(k => string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase))) return candidate; } return "seite_" + DateTime.UtcNow.Ticks; } private void NotifyEditor(BasePlayer player, IPlayer iPlayer, string message) { if (iPlayer != null) iPlayer.Reply(message); else if (player != null) player.ChatMessage(message); } private void TryCreateNewPage(BasePlayer player, string categoryKey, string pageKey, string titleOpt, IPlayer iPlayer) { if (player == null || _config.Pages == null) return; categoryKey = (categoryKey ?? string.Empty).Trim().ToLowerInvariant(); pageKey = (pageKey ?? string.Empty).Trim().ToLowerInvariant(); if (!IsValidHelpSlug(categoryKey) || !IsValidHelpSlug(pageKey)) { NotifyEditor(player, iPlayer, "Ungültiger Name: nur Kleinbuchstaben, Ziffern, - und _, Länge 2–48. Beispiel: /helpedit newpage server regeln"); return; } if (!_config.Pages.TryGetValue(categoryKey, out var category) || category?.Entries == null) { NotifyEditor(player, iPlayer, "Kategorie nicht gefunden: " + categoryKey + " – zuerst /helpedit newcategory " + categoryKey); return; } if (category.Entries.Keys.Any(k => string.Equals(k, pageKey, StringComparison.OrdinalIgnoreCase))) { NotifyEditor(player, iPlayer, "Seite existiert bereits: " + categoryKey + " / " + pageKey); return; } var title = string.IsNullOrWhiteSpace(titleOpt) ? pageKey : titleOpt.Trim(); category.Entries[pageKey] = new PageConfig { Title = title, Content = "Neuer Inhalt – hier Text einfügen.\n\nZeilenumbrüche mit Enter im Editor.\n\nHinweis: Rust-CUI zeigt meist nur Klartext; farbige Tags siehst du u. U. nur in der Chat-Ausgabe (Button unten).", FontSize = 15, Center = false, IncreaseHeightValue = 0, AdminOnly = false }; SaveConfig(); NotifyEditor(player, iPlayer, "Neue Seite angelegt: " + categoryKey + " / " + pageKey); OpenEditUi(player, categoryKey, pageKey); } private void TryCreateNewCategory(BasePlayer player, string categoryKey, string titleOpt, IPlayer iPlayer) { if (player == null || _config.Pages == null) return; categoryKey = (categoryKey ?? string.Empty).Trim().ToLowerInvariant(); if (!IsValidHelpSlug(categoryKey)) { NotifyEditor(player, iPlayer, "Ungültiger Kategoriename (a–z, 0–9, -, _). Beispiel: /helpedit newcategory extras"); return; } if (_config.Pages.ContainsKey(categoryKey)) { NotifyEditor(player, iPlayer, "Kategorie existiert bereits: " + categoryKey); return; } var title = string.IsNullOrWhiteSpace(titleOpt) ? char.ToUpper(categoryKey[0]) + categoryKey.Substring(1) : titleOpt.Trim(); _config.Pages[categoryKey] = new CategoryConfig { Title = title, Entries = new Dictionary { ["welcome"] = new PageConfig { Title = "Willkommen", Content = "Neue Kategorie.\n\nText hier oder im Editor anpassen.", FontSize = 15, Center = false, IncreaseHeightValue = 0, AdminOnly = false } } }; if (_config.MainCategoryOrder == null) _config.MainCategoryOrder = new List(); if (_config.MainCategoryOrder.Count < MaxMainCategories && !_config.MainCategoryOrder.Any(x => string.Equals(x, categoryKey, StringComparison.OrdinalIgnoreCase))) _config.MainCategoryOrder.Add(categoryKey); else if (!_config.MainCategoryOrder.Any(x => string.Equals(x, categoryKey, StringComparison.OrdinalIgnoreCase))) NotifyEditor(player, iPlayer, "Hinweis: \"Main Category Order\" hat bereits 5 Einträge – neue Kategorie ist in der JSON sichtbar; ggf. Reihenfolge anpassen."); SaveConfig(); NotifyEditor(player, iPlayer, "Neue Kategorie angelegt: " + categoryKey); OpenEditUi(player, categoryKey, "welcome"); } private bool MatchesPendingDelete(ulong userId, string kind, string cat, string page) { if (!_pendingDeletes.TryGetValue(userId, out var p)) return false; if (p.Kind != kind) return false; if (!string.Equals(p.CategoryKey, cat, StringComparison.OrdinalIgnoreCase)) return false; if (kind == "page" && !string.Equals(p.PageKey ?? string.Empty, page ?? string.Empty, StringComparison.OrdinalIgnoreCase)) return false; return UnityEngine.Time.realtimeSinceStartup - p.Time <= PendingDeleteConfirmSeconds; } private void SetPendingDelete(ulong userId, string kind, string cat, string page) { _pendingDeletes[userId] = new PendingDeleteConfirm { Kind = kind, CategoryKey = cat, PageKey = page, Time = UnityEngine.Time.realtimeSinceStartup }; } private void TryDeletePage(BasePlayer player, string categoryKey, string pageKey, IPlayer iPlayer) { if (player == null || _config.Pages == null) return; categoryKey = (categoryKey ?? string.Empty).Trim().ToLowerInvariant(); pageKey = (pageKey ?? string.Empty).Trim().ToLowerInvariant(); var catKeyExact = _config.Pages.Keys.FirstOrDefault(k => string.Equals(k, categoryKey, StringComparison.OrdinalIgnoreCase)); if (catKeyExact == null || !_config.Pages.TryGetValue(catKeyExact, out var cat) || cat?.Entries == null) { NotifyEditor(player, iPlayer, "Seite nicht gefunden."); return; } var pageKeyExact = cat.Entries.Keys.FirstOrDefault(k => string.Equals(k, pageKey, StringComparison.OrdinalIgnoreCase)); if (pageKeyExact == null || !cat.Entries.TryGetValue(pageKeyExact, out var pageCfg)) { NotifyEditor(player, iPlayer, "Seite nicht gefunden."); return; } if (!IsPageVisibleToPlayer(pageCfg, player)) { NotifyEditor(player, iPlayer, "Keine Berechtigung für diese Seite."); return; } if (pageCfg.AdminOnly && !player.IsAdmin) { NotifyEditor(player, iPlayer, "Nur Server-Admins dürfen diese Seite löschen."); return; } if (cat.Entries.Count <= 1) { NotifyEditor(player, iPlayer, "Letzte Seite in dieser Kategorie – zuerst neue Seite anlegen oder Kategorie löschen (/helpedit delcategory " + catKeyExact + ")."); return; } cat.Entries.Remove(pageKeyExact); SaveConfig(); _editDrafts.Remove(player.userID); _pendingDeletes.Remove(player.userID); NotifyEditor(player, iPlayer, "Seite gelöscht: " + catKeyExact + " / " + pageKeyExact); CuiHelper.DestroyUi(player, UiNameEdit); var next = GetPageKeys(catKeyExact, player).FirstOrDefault(); if (!string.IsNullOrEmpty(next)) OpenUi(player, catKeyExact, next); else OpenUi(player, null, null); } private void TryDeleteCategory(BasePlayer player, string categoryKey, IPlayer iPlayer) { if (player == null || _config.Pages == null) return; categoryKey = (categoryKey ?? string.Empty).Trim().ToLowerInvariant(); var catKeyExact = _config.Pages.Keys.FirstOrDefault(k => string.Equals(k, categoryKey, StringComparison.OrdinalIgnoreCase)); if (catKeyExact == null || !_config.Pages.TryGetValue(catKeyExact, out var cat) || cat?.Entries == null) { NotifyEditor(player, iPlayer, "Kategorie nicht gefunden."); return; } foreach (var kv in cat.Entries) { if (kv.Value != null && kv.Value.AdminOnly && !player.IsAdmin) { NotifyEditor(player, iPlayer, "Kategorie enthält Admin-Seiten – nur Server-Admin kann die Kategorie löschen."); return; } } if (_config.Pages.Count <= 1) { NotifyEditor(player, iPlayer, "Die letzte verbleibende Kategorie kann nicht gelöscht werden."); return; } _config.Pages.Remove(catKeyExact); if (_config.MainCategoryOrder != null) { for (int i = _config.MainCategoryOrder.Count - 1; i >= 0; i--) { if (string.Equals(_config.MainCategoryOrder[i], catKeyExact, StringComparison.OrdinalIgnoreCase)) _config.MainCategoryOrder.RemoveAt(i); } } SaveConfig(); _editDrafts.Remove(player.userID); _pendingDeletes.Remove(player.userID); NotifyEditor(player, iPlayer, "Kategorie gelöscht: " + catKeyExact); CuiHelper.DestroyUi(player, UiNameEdit); var mk = GetMainCategoryKeys(); if (mk.Count == 0) { player.ChatMessage(T("NoCategories", player.UserIDString)); return; } foreach (var c in mk) { var pk = GetPageKeys(c, player); if (pk.Count > 0) { OpenUi(player, c, pk[0]); return; } } player.ChatMessage(T("NoPageVisible", player.UserIDString)); } [ConsoleCommand("hcui.edit.delpage")] private void CmdEditDelPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (!_editDrafts.TryGetValue(player.userID, out var d)) return; if (!MatchesPendingDelete(player.userID, "page", d.CategoryKey, d.PageKey)) { SetPendingDelete(player.userID, "page", d.CategoryKey, d.PageKey); player.ChatMessage(T("ConfirmDelPage", player.UserIDString, (int)PendingDeleteConfirmSeconds)); return; } _pendingDeletes.Remove(player.userID); TryDeletePage(player, d.CategoryKey, d.PageKey, null); } [ConsoleCommand("hcui.edit.delcategory")] private void CmdEditDelCategory(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (!_editDrafts.TryGetValue(player.userID, out var d)) return; if (!MatchesPendingDelete(player.userID, "cat", d.CategoryKey, null)) { SetPendingDelete(player.userID, "cat", d.CategoryKey, null); player.ChatMessage(T("ConfirmDelCat", player.UserIDString, (int)PendingDeleteConfirmSeconds)); return; } _pendingDeletes.Remove(player.userID); TryDeleteCategory(player, d.CategoryKey, null); } [ConsoleCommand("hcui.edit.newpage")] private void CmdEditNewPageConsole(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; string cat; string pageKey; if (arg.Args != null && arg.Args.Length >= 2) { cat = arg.Args[0].Trim().ToLowerInvariant(); pageKey = arg.Args[1].Trim().ToLowerInvariant(); } else if (_editDrafts.TryGetValue(player.userID, out var draft)) { cat = draft.CategoryKey; pageKey = GenerateUniquePageKey(cat); } else if (_uiStates.TryGetValue(player.userID, out var st)) { cat = st.CategoryKey; pageKey = GenerateUniquePageKey(cat); } else { player.ChatMessage(T("OpenHelpFirst", player.UserIDString)); return; } TryCreateNewPage(player, cat, pageKey, null, null); } [ConsoleCommand("hcui.edit.newcategory")] private void CmdEditNewCategoryConsole(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (arg.Args != null && arg.Args.Length >= 1) { var catKey = arg.Args[0].Trim().ToLowerInvariant(); var title = arg.Args.Length > 1 ? string.Join(" ", arg.Args.Skip(1)) : null; TryCreateNewCategory(player, catKey, title, null); return; } var generated = "kat_" + UnityEngine.Random.Range(10000, 99999); TryCreateNewCategory(player, generated, null, null); } [ConsoleCommand("hcui.edit.save")] private void CmdEditSave(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !HasEditPermission(player)) return; if (!_editDrafts.TryGetValue(player.userID, out var d)) return; if (!TryGetPage(d.CategoryKey, d.PageKey, out var pageCfg) || !IsPageVisibleToPlayer(pageCfg, player)) { player.ChatMessage(T("PageInvalid", player.UserIDString)); return; } if (pageCfg.AdminOnly && !player.IsAdmin) return; pageCfg.Title = (d.Title ?? string.Empty).Trim(); pageCfg.Content = d.Content ?? string.Empty; SaveConfig(); player.ChatMessage(T("HelpSaved", player.UserIDString, d.CategoryKey, d.PageKey)); CuiHelper.DestroyUi(player, UiNameEdit); OpenUi(player, d.CategoryKey, d.PageKey); } [ConsoleCommand("hcui.edit.cancel")] private void CmdEditCancel(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CuiHelper.DestroyUi(player, UiNameEdit); _editDrafts.Remove(player.userID); if (_uiStates.TryGetValue(player.userID, out var st)) OpenUi(player, st.CategoryKey, st.PageKey); else OpenUi(player, null, null); } private string RenderPageContent(string raw) { string content = raw ?? string.Empty; if (content.Contains("{{AUTO_COMMANDS}}")) content = content.Replace("{{AUTO_COMMANDS}}", _cachedPublicCommandsText ?? string.Empty); if (content.Contains("{{AUTO_COMMANDS_PUBLIC}}")) content = content.Replace("{{AUTO_COMMANDS_PUBLIC}}", _cachedPublicCommandsText ?? string.Empty); if (content.Contains("{{AUTO_COMMANDS_INTERNAL}}")) content = content.Replace("{{AUTO_COMMANDS_INTERNAL}}", _cachedInternalCommandsText ?? string.Empty); if (content.Contains("{{TEAM_ROSTER}}")) content = content.Replace("{{TEAM_ROSTER}}", _cachedTeamRosterText ?? string.Empty); return content; } private void RebuildDynamicCaches() { _cachedPublicCommandsText = BuildCommandsIndexFromPlugins(includeInternal: false); _cachedInternalCommandsText = BuildCommandsIndexFromPlugins(includeInternal: true); _cachedTeamRosterText = BuildTeamRosterText(); } private string BuildCommandsIndexFromPlugins(bool includeInternal) { try { var sb = new StringBuilder(); sb.AppendLine(includeInternal ? "AUTO-LISTE INTERNE/ADMIN BEFEHLE (PLUGIN-SCAN)" : "AUTO-LISTE SPIELERBEFEHLE (PLUGIN-SCAN)"); sb.AppendLine("Stand: " + DateTime.Now.ToString("dd.MM.yyyy HH:mm")); sb.AppendLine(); string pluginsDir = ResolvePluginsDirectory(); if (string.IsNullOrEmpty(pluginsDir) || !Directory.Exists(pluginsDir)) return "Plugin-Ordner nicht gefunden (Pfadauflösung fehlgeschlagen)."; var files = Directory.GetFiles(pluginsDir, "*.cs", SearchOption.TopDirectoryOnly); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var entries = new List<(string plugin, string type, string cmd)>(); var infoRegex = new Regex("\\[Info\\(\"([^\"]+)\"", RegexOptions.Compiled); var chatRegex = new Regex("\\[ChatCommand\\(\"([^\"]+)\"\\)\\]", RegexOptions.Compiled); var consoleRegex = new Regex("\\[ConsoleCommand\\(\"([^\"]+)\"\\)\\]", RegexOptions.Compiled); var covRegex = new Regex("AddCovalenceCommand\\(\"([^\"]+)\"", RegexOptions.Compiled); foreach (var file in files) { string text; try { text = File.ReadAllText(file); } catch { continue; } string pluginName = Path.GetFileNameWithoutExtension(file); var infoMatch = infoRegex.Match(text); if (infoMatch.Success && !string.IsNullOrWhiteSpace(infoMatch.Groups[1].Value)) pluginName = infoMatch.Groups[1].Value.Trim(); foreach (Match m in chatRegex.Matches(text)) { string cmd = m.Groups[1].Value.Trim(); if (string.IsNullOrWhiteSpace(cmd)) continue; if (!includeInternal && !IsPublicPlayerCommand(cmd, "Chat")) continue; if (includeInternal && IsPublicPlayerCommand(cmd, "Chat")) continue; string key = pluginName + "|Chat|/" + cmd; if (seen.Add(key)) entries.Add((pluginName, "Chat", "/" + cmd)); } foreach (Match m in consoleRegex.Matches(text)) { if (!includeInternal && !AutoCommandsIncludeConsole) continue; string cmd = m.Groups[1].Value.Trim(); if (string.IsNullOrWhiteSpace(cmd)) continue; if (!includeInternal && !IsPublicPlayerCommand(cmd, "Konsole")) continue; if (includeInternal && IsPublicPlayerCommand(cmd, "Konsole")) continue; string key = pluginName + "|Konsole|" + cmd; if (seen.Add(key)) entries.Add((pluginName, "Konsole", cmd)); } foreach (Match m in covRegex.Matches(text)) { string cmd = m.Groups[1].Value.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(cmd)) continue; if (!includeInternal && !IsPublicPlayerCommand(cmd, "Covalence")) continue; if (includeInternal && IsPublicPlayerCommand(cmd, "Covalence")) continue; string key = pluginName + "|Covalence|/" + cmd; if (seen.Add(key)) entries.Add((pluginName, "Covalence", "/" + cmd)); } } foreach (var group in entries.OrderBy(e => e.plugin).ThenBy(e => e.type).ThenBy(e => e.cmd).GroupBy(e => e.plugin)) { sb.AppendLine("[" + group.Key + "]"); foreach (var entry in group) sb.AppendLine("- " + entry.type + ": " + entry.cmd); sb.AppendLine(); } if (entries.Count == 0) return includeInternal ? "Keine internen Befehle gefunden." : "Keine Spielerbefehle gefunden."; return sb.ToString().TrimEnd(); } catch (Exception ex) { return "Fehler beim Befehls-Scan: " + ex.Message; } } private bool IsPublicPlayerCommand(string command, string type) { if (string.IsNullOrWhiteSpace(command)) return false; string cmd = command.Trim().ToLowerInvariant(); // Interne/technische Prefixes rausfiltern. if (cmd.StartsWith("global.") || cmd.StartsWith("server.") || cmd.StartsWith("debug.") || cmd.StartsWith("oxide.") || cmd.StartsWith("rcon.") || cmd.StartsWith("app.")) return false; // Für normale Spieler relevante Kommandos behalten, Admin-/Maintenance-Kommandos ausblenden. string[] blockedTokens = { "admin", "debug", "reload", "unload", "load", "grant", "revoke", "perm", "permission", "kick", "ban", "wipe", "restart", "shutdown", "killall", "healore", "tpconsole", "convar", "helpreload" }; foreach (var token in blockedTokens) { if (cmd.Contains(token)) return false; } // Console-Kommandos sind standardmäßig komplett deaktiviert (siehe Konstante oben). if (type == "Konsole" && !AutoCommandsIncludeConsole) return false; return true; } private string ResolvePluginsDirectory() { try { var candidates = new List(); if (!string.IsNullOrWhiteSpace(Interface.Oxide.RootDirectory)) candidates.Add(Path.Combine(Interface.Oxide.RootDirectory, "plugins")); if (!string.IsNullOrWhiteSpace(Interface.Oxide.ConfigDirectory)) { candidates.Add(Path.Combine(Interface.Oxide.ConfigDirectory, "..", "plugins")); var configParent = Path.GetDirectoryName(Interface.Oxide.ConfigDirectory); if (!string.IsNullOrWhiteSpace(configParent)) candidates.Add(Path.Combine(configParent, "plugins")); } foreach (var candidate in candidates) { if (string.IsNullOrWhiteSpace(candidate)) continue; string normalized; try { normalized = Path.GetFullPath(candidate); } catch { continue; } if (Directory.Exists(normalized)) return normalized; } } catch { // Ignorieren und leer zurückgeben. } return string.Empty; } private string BuildTeamRosterText() { var sb = new StringBuilder(); sb.AppendLine("TEAM / MODERATION"); sb.AppendLine("Stand: " + DateTime.Now.ToString("dd.MM.yyyy HH:mm")); sb.AppendLine(); AppendGroupRoster(sb, "owner", "Owner"); AppendGroupRoster(sb, "admin", "Admins"); AppendGroupRoster(sb, "developer", "Developer"); AppendGroupRoster(sb, "dev", "Devs"); AppendGroupRoster(sb, "moderator", "Moderatoren"); AppendGroupRoster(sb, "mod", "Mods"); return sb.ToString().TrimEnd(); } private void AppendGroupRoster(StringBuilder sb, string group, string title) { if (!permission.GroupExists(group)) return; sb.AppendLine(title + ":"); var users = permission.GetUsersInGroup(group); if (users == null || users.Length == 0) { sb.AppendLine("- (keine Einträge)"); sb.AppendLine(); return; } foreach (var raw in users.OrderBy(x => x)) { string line = (raw ?? string.Empty).Trim(); if (string.IsNullOrEmpty(line)) continue; string[] split = line.Split(' '); string id = split.Length > 0 ? split[0] : line; string fallbackName = split.Length > 1 ? string.Join(" ", split.Skip(1)) : id; string display = fallbackName; if (ulong.TryParse(id, out ulong uid)) { var online = BasePlayer.FindByID(uid) ?? BasePlayer.FindSleeping(uid); if (online != null && !string.IsNullOrWhiteSpace(online.displayName)) display = online.displayName; } sb.AppendLine("- " + display + " (" + id + ")"); } sb.AppendLine(); } private List GetMainCategoryKeys() { var valid = new List(); if (_config.Pages == null) return valid; foreach (var key in _config.MainCategoryOrder) { if (valid.Count >= MaxMainCategories) break; string k = (key ?? string.Empty).Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(k) || valid.Contains(k)) continue; if (_config.Pages.ContainsKey(k)) valid.Add(k); } foreach (var key in _config.Pages.Keys) { if (valid.Count >= MaxMainCategories) break; string k = key.ToLowerInvariant(); if (!valid.Contains(k)) valid.Add(k); } return valid; } private string ResolveCategoryKey(string requested, List mainKeys) { string key = (requested ?? string.Empty).Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(key) && mainKeys.Contains(key)) return key; return mainKeys[0]; } private string ResolvePageKey(string categoryKey, string requestedPage, BasePlayer player) { var pageKeys = GetPageKeys(categoryKey, player); if (pageKeys.Count == 0) return string.Empty; string page = (requestedPage ?? string.Empty).Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(page) && pageKeys.Contains(page)) return page; return pageKeys[0]; } private List GetPageKeys(string categoryKey, BasePlayer player = null) { if (string.IsNullOrEmpty(categoryKey) || _config.Pages == null || !_config.Pages.TryGetValue(categoryKey, out var category) || category.Entries == null) return new List(); var keys = new List(); foreach (var kv in category.Entries) { if (!IsPageVisibleToPlayer(kv.Value, player)) continue; keys.Add(kv.Key.ToLowerInvariant()); } return keys; } private string GetCategoryTitle(string categoryKey) { if (_config.Pages != null && _config.Pages.TryGetValue(categoryKey, out var category) && !string.IsNullOrEmpty(category.Title)) return category.Title; return categoryKey; } private bool TryGetPage(string categoryKey, string pageKey, out PageConfig page) { page = null; if (string.IsNullOrEmpty(categoryKey) || string.IsNullOrEmpty(pageKey)) return false; if (_config.Pages == null || !_config.Pages.TryGetValue(categoryKey, out var category)) return false; if (category.Entries == null || !category.Entries.TryGetValue(pageKey, out page)) return false; return true; } private bool IsPageVisibleToPlayer(PageConfig page, BasePlayer player) { if (page == null) return false; if (!page.AdminOnly) return true; return IsHelpAdmin(player); } private bool IsHelpAdmin(BasePlayer player) { return player != null && (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin)); } private static List Paginate(string text, int linesPerPart) { var lines = SplitLines(text); var parts = new List(); if (lines.Count == 0) { parts.Add(string.Empty); return parts; } for (int i = 0; i < lines.Count; i += linesPerPart) { int count = Math.Min(linesPerPart, lines.Count - i); parts.Add(string.Join("\n", lines.GetRange(i, count))); } return parts; } private static List SplitLines(string text) { return (text ?? string.Empty).Replace("\r", "").Split('\n').ToList(); } private static void SplitTarget(string target, out string category, out string pageKey) { category = null; pageKey = null; var parts = (target ?? string.Empty).Split(':'); if (parts.Length >= 1 && !string.IsNullOrWhiteSpace(parts[0])) category = parts[0].Trim().ToLowerInvariant(); if (parts.Length >= 2 && !string.IsNullOrWhiteSpace(parts[1])) pageKey = parts[1].Trim().ToLowerInvariant(); } } }