diff --git a/.gitattributes b/.gitattributes index 1dcfd03d52..c2a0d29c5f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,6 +16,8 @@ src/shaders/compiled/*.msl text eol=lf whitespace=-blank-at-eof src/bin/Data/Items/**/*.json text eol=lf # The effect catalogue is checked against the format its writer produces (LF). src/bin/Data/Effects/*.json text eol=lf +# The server list is written by the editor's Server List window with LF. +src/bin/Data/Local/ServerList.json text eol=lf # The creation baseline of the effect recorder is compared line by line on every platform. tests/effects/baseline/*.txt text eol=lf diff --git a/docs/dev-editor.md b/docs/dev-editor.md index 787153abab..f122181ece 100644 --- a/docs/dev-editor.md +++ b/docs/dev-editor.md @@ -36,19 +36,23 @@ the game window; **Close Editor** is on the right of the editor window's menu bar. The editor opens in a window of its own, beside the game window where the -screen has room (else over its right side), and stays in front of the game. -Its **Windows** menu opens the tool windows: Item, Skill, Dev and Map Editor, -the Effect Browser and the console. They open as tabs docked in the editor -window, the console along the bottom. Drag a tab out to float a tool window -anywhere, over the game window too, or onto another window to dock it there; -**Windows > Reset Layout** docks them all back. The layout and the windows' -places are kept between sessions (`imgui.ini` in the game folder). - -The editor window's bar has the **Windows** menu, **Undo** and **Redo** of the -item tools, the UI scale and the language. A window of tool windows floating -outside the editor window has the same bar once, on the tab shown at its top -left when several are docked in it, so it can be worked with from another -monitor; its **Windows > Fullscreen** switches that window to full screen. +screen has room (else over its right side), and stays in front of the game. Its +**Windows** menu opens the tool windows: Item, Server List +([server-list.md](server-list.md)), Skill, Dev and Map Editor, the Effect +Browser and the console. They open as tabs docked in the editor window, the +console along the bottom. Drag a tab out to float a tool window anywhere, over +the game window too, or onto another window to dock it there; **Windows > Reset +Layout** docks them all back. The layout and the windows' places are kept +between sessions (`imgui.ini` in the game folder). + +The editor window's bar has the **Windows** menu, **Undo** and **Redo**, the UI +scale and the language. Undo and Redo act on the tool used last: after a click +into the Server List window on its changes, after a click into an item tool on +item edits; while the Server List window is closed, on item edits. A window of +tool windows floating outside the editor window has the same bar once, on the +tab shown at its top left when several are docked in it, so it can be worked +with from another monitor; its **Windows > Fullscreen** switches that window to +full screen. The editor window, and a tool window floating outside the game window, are ordinary windows of the system, with its title bar, buttons and window diff --git a/docs/server-list.md b/docs/server-list.md new file mode 100644 index 0000000000..0fe2cf8711 --- /dev/null +++ b/docs/server-list.md @@ -0,0 +1,110 @@ +# Server List + +The server groups on the login screen (Valhalla, Helheim, ...) and the names +and colors of their channels come from `Data/Local/ServerList.json`. It +replaced the encrypted `Data/Local/ServerList.bmd` of the original client. The +game reads it at start; with errors in it the game shows them and stops, as it +does for broken item data. All problems are also written to the log. + +## How the login screen uses it + +The connect server sends the IDs of its game servers and their load. The +client splits each ID into a group and a channel: + +- group = server ID / 20 +- channel = server ID % 20 + 1 + +So servers 0 to 19 are the channels 1 to 20 of group 0, servers 20 to 39 those +of group 1, and so on. On OpenMU the ID is the game server's *Server ID* in the +admin panel; to put a server into a second group, give it an ID from 20 to 39. + +- **Groups:** one button per group, in the order of the list. A server whose + group is not in the list is not shown at all. +- **Test server:** of the groups marked as test server, the login screen + shows only the first, at its place in the list; the others are hidden. +- **Channels:** each channel is shown as `-`, followed by + its type: + + | Type | Shown as | Button | + |-----------|------------------------|---------| + | `normal` | `Valhalla-1` | gray | + | `nonPvp` | `Valhalla-1(Non-PVP)` | default | + | `goldPvp` | `Valhalla-1(Gold PVP)` | orange | + | `gold` | `Valhalla-1(Gold)` | orange | + + The type only names and colors the channel; whether PvP is on is decided by + the server. +- **PvP notice:** the warning about crowded PvP servers is shown for a group + that has no `nonPvp` and no `gold` channel. +- **Description:** shown below the channels while the group is selected. + +## The file + +```json +{ + "formatVersion": 1, + "groups": [ + { + "id": 0, + "name": "Valhalla", + "channels": {"1": "gold", "5": "goldPvp", "6": "nonPvp"} + }, + { + "id": 1, + "name": "Helheim", + "description": "PvP everywhere" + }, + { + "id": 9, + "name": "Test", + "testServer": true + } + ] +} +``` + +| Field | Meaning | +|---------------|--------------------------------------------------------------------------| +| `id` | The group: server ID / 20, from 0 to 3276. Each group once. | +| `name` | The button text and the start of each channel name; up to 32 characters. | +| `description` | Optional; up to 254 characters. | +| `testServer` | Optional; `true` marks a test server group: only the first is shown. | +| `channels` | Optional; the type of a channel by its number (1 to 20). Channels not listed are `normal`. | + +A name or description that is too long is cut (a warning in the log). Most +emoji count as two characters, as the client stores them on Windows. + +## Editing in MU Editor + +**Windows → Server List** shows the groups in their order on the left and the +selected group on the right: its ID (with the server IDs it covers), name, +description, the test server mark, and a type for each of the 20 channels with +the name the login screen gives it. + +- The login screen shows each change right away, while the window is open. +- **Add**, **Remove**, **Move up** and **Move down** change the list. +- When the connect server sent servers of groups the list does not have, the + window lists them with their server IDs; **Add** there adds a group with that + ID. The channel table shows which channels the connect server sent, with + their load. +- **Save** writes `Data/Local/ServerList.json` in the folder the game runs + from. It refuses a list with errors (a group without a name, an ID used + twice) and lists them. When the file itself had errors as it was read + (changed by hand while the game ran), the window has only the groups that + could be read: Save stays off then, so it cannot remove the others, until + the file is fixed and **Revert** reads it again. +- **Revert** reads the file again. +- **Undo** and **Redo** in the editor bar (or Ctrl+Z and Ctrl+Y in the window) + work through the changes of the session. The bar's buttons act on the tool + used last: after a click into the Server List window they undo server list + changes, after a click into an item tool item edits. + +## Compared to the original client + +`ServerList.bmd` held the same groups, encrypted, with channel types for the +first 15 channels only and a left, right or center position for each button. +The original screen drew the center one, a test server, as a button of its own; +the login screen now lists all groups in one column in the list's order, so +only "test server" is kept, for the rule that only the first is shown. The +original loader read each group's description but never kept it, so the login +screen showed none; the JSON description is shown. diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 820ef1746e..21ce8b4c5b 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -1852,4 +1852,118 @@ Erst die Änderungen anwenden oder zurücksetzen. + + Serverliste + + + Speichern + + + Liest {0} neu ein; ungespeicherte Änderungen gehen verloren. + + + Ungespeicherte Änderungen + + + In {0} gespeichert + + + Nicht gespeichert, siehe die Probleme unten. + + + Probleme in {0}: + + + Neue Gruppe + + + Gruppen-ID + + + Server {0} bis {1} + + + Eine andere Gruppe hat die ID {0}. + + + Eine Gruppe braucht einen Namen. + + + Beschreibung + + + Steht unter den Kanälen, solange die Gruppe gewählt ist. + + + Testserver + + + Der Anmeldebildschirm zeigt nur die erste Testserver-Gruppe, an ihrem Platz in der Liste. + + + Kanal + + + Art + + + Auf dem Anmeldebildschirm + + + Connect-Server + + + Normal + + + Kein PvP + + + Gold-PvP + + + Gold + + + Gemeldet, {0} % Last + + + Der Connect-Server meldet Server von Gruppen, die die Liste nicht hat; der Anmeldebildschirm zeigt sie nicht: + + + Gruppe {0}: Server {1} + + + Wählen Sie eine Gruppe in der Liste. + + + Die Reihenfolge der Liste ist die der Schaltflächen auf dem Anmeldebildschirm, der jede Änderung sofort zeigt. Speichern schreibt {0}. + + + Die Datei hatte beim Lesen Fehler, daher enthält die Liste nur die Gruppen, die gelesen werden konnten. Speichern bleibt aus, bis die Datei korrigiert ist und Zurücksetzen sie neu einliest; es würde die anderen Gruppen aus der Datei entfernen. + + + {0} hinzufügen + + + {0} entfernen + + + {0} verschieben + + + {0}: Name + + + {0}: ID + + + {0}: Beschreibung + + + {0}: Testserver + + + {0}: Kanal {1} + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index cd0b111810..3556972544 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -1873,4 +1873,118 @@ Apply or revert the changes first. + + Server List + + + Save + + + Reads {0} again; unsaved changes are lost. + + + Unsaved changes + + + Saved to {0} + + + Not saved, see the problems below. + + + Problems in {0}: + + + New Group + + + Group ID + + + Servers {0} to {1} + + + Another group has the ID {0}. + + + A group needs a name. + + + Description + + + Shown below the channels while the group is selected. + + + Test server + + + The login screen shows only the first test server group, at its place in the list. + + + Channel + + + Type + + + On the login screen + + + Connect server + + + Normal + + + Non-PvP + + + Gold PvP + + + Gold + + + Sent, {0}% load + + + The connect server sent servers of groups the list does not have; the login screen does not show them: + + + Group {0}: servers {1} + + + Pick a group in the list. + + + The order of the list is the order of the buttons on the login screen, which shows each change right away. Save writes {0}. + + + The file had errors when it was read, so the list has only the groups that could be read. Save is off until the file is fixed and Revert reads it again; it would remove the other groups from the file. + + + Add {0} + + + Remove {0} + + + Move {0} + + + {0}: name + + + {0}: ID + + + {0}: description + + + {0}: test server + + + {0}: channel {1} + diff --git a/src/MuEditor/Core/MuEditorCore.cpp b/src/MuEditor/Core/MuEditorCore.cpp index 8a98f83899..301999fb5b 100644 --- a/src/MuEditor/Core/MuEditorCore.cpp +++ b/src/MuEditor/Core/MuEditorCore.cpp @@ -33,6 +33,7 @@ #include "../MuEditor/UI/MapEditor/MapEditorUI.h" #include "../MuEditor/UI/EffectBrowser/MuEffectBrowserUI.h" #include "../MuEditor/UI/ItemTranslations/MuItemTranslationsUI.h" +#include "../MuEditor/UI/ServerList/MuServerListUI.h" #include "../UI/Common/MuEditorUI.h" #include "../UI/Console/MuEditorConsoleUI.h" #include "I18N/All.h" @@ -876,6 +877,10 @@ void CMuEditorCore::Render() { g_MuItemTranslationsUI.Render(&m_windows.itemTranslations); } + if (m_windows.serverList) + { + g_MuServerListUI.Render(&m_windows.serverList); + } RenderItemTools(); // Render console (if enabled) diff --git a/src/MuEditor/README.md b/src/MuEditor/README.md index 3f52ec1fec..1c6df64d23 100644 --- a/src/MuEditor/README.md +++ b/src/MuEditor/README.md @@ -22,6 +22,7 @@ The MU Editor is a C++ ImGui-based in-game editor that provides real-time game e - **MuEditor.h/cpp** - Main editor class, initialization, lifecycle management - **MuEditorUI.h/cpp** - The editor window and its menu bar, or the Open Editor button while closed - **MuEditorLayout.h/cpp** - Where the editor window first opens, and the first layout of its tool windows +- **UndoTarget.h/cpp** - The editor bar's Undo and Redo, for the tool worked in last (the item tools or the Server List window) - **MuEditorWindowIds.h** - The names the tool windows are docked by - **MuEditorPlatformWindows.h/cpp** - The editor's own OS windows on Windows, Linux (X11) and macOS - **MuEditorCursor.h/cpp** - The OS cursor's shape over the editor off Windows @@ -44,7 +45,7 @@ stores it when a field is left. - **MuItemGlowColorsUI** - The glow color list: new colors, changed values and unused colors removed (GlowColorDialog, which the model tool's color fields open too) - **MuItemFormulasUI** - The item stat file: drop level bonus, default, profiles, level bonus tables and profile rules, changed on a copy and applied after the list of the items it reaches (ItemStatTermFields, ItemStatFileChange) - **MuItemChecksUI** - The checks of a save, each leading to its item and field (ItemCheckTargets) -- **ItemToolWindow** - What every item tool window shares: its title, the selected item, and Undo and Redo (shown in the editor's bar) +- **ItemToolWindow** - What every item tool window shares: its title, the selected item, and the item edit history as the editor bar's undo target ### Item Actions (UI/ItemEditor) - **ItemEditorActions.h/cpp** - Save, import from bmd and the exports @@ -53,6 +54,11 @@ stores it when a field is left. ### Item Translations - **MuItemTranslationsUI.h/cpp** - Item names as a grid of items and languages, with missing counts (docs/translation-system.md) +### Server List (UI/ServerList) +- **MuServerListUI.h/cpp** - The server groups of the login screen (Data/Local/ServerList.json): order, names, test server and channel types, shown on the login screen right away (docs/server-list.md) +- **ServerListEdits.h/cpp** - The groups being changed, what the file has, and their undo history (the editor bar's Undo and Redo while the window was used last) +- **ServerListStepLabel.h/cpp** - What each undo step did ("Remove Helheim", "Valhalla: channel 3"), for the tooltips of the editor bar's Undo and Redo + ### Input Management - **MuInputBlocker.h/cpp** - Blocks game input when hovering over ImGui UI diff --git a/src/MuEditor/UI/Common/MuEditorLayout.cpp b/src/MuEditor/UI/Common/MuEditorLayout.cpp index ea5c3efae0..80c7d45535 100644 --- a/src/MuEditor/UI/Common/MuEditorLayout.cpp +++ b/src/MuEditor/UI/Common/MuEditorLayout.cpp @@ -47,8 +47,9 @@ void BuildDefaultLayout(ImGuiID dockSpace, ImVec2 size) { ImGui::DockBuilderDockWindow(window, otherTools); } - for (const char* window : {MuEditor::WindowIds::SkillEditor, MuEditor::WindowIds::DevEditor, - MuEditor::WindowIds::MapEditor, MuEditor::WindowIds::EffectBrowser}) + for (const char* window : + {MuEditor::WindowIds::SkillEditor, MuEditor::WindowIds::DevEditor, MuEditor::WindowIds::MapEditor, + MuEditor::WindowIds::EffectBrowser, MuEditor::WindowIds::ServerList}) { ImGui::DockBuilderDockWindow(window, otherTools); } diff --git a/src/MuEditor/UI/Common/MuEditorToolWindows.h b/src/MuEditor/UI/Common/MuEditorToolWindows.h index c16294d08d..daa0c22dca 100644 --- a/src/MuEditor/UI/Common/MuEditorToolWindows.h +++ b/src/MuEditor/UI/Common/MuEditorToolWindows.h @@ -19,6 +19,7 @@ struct ToolWindows bool itemFormulas = false; bool itemChecks = false; bool itemTranslations = false; + bool serverList = false; bool skillEditor = false; bool devEditor = false; bool mapEditor = false; diff --git a/src/MuEditor/UI/Common/MuEditorUI.cpp b/src/MuEditor/UI/Common/MuEditorUI.cpp index 67d3a77c29..0d65b8ae8d 100644 --- a/src/MuEditor/UI/Common/MuEditorUI.cpp +++ b/src/MuEditor/UI/Common/MuEditorUI.cpp @@ -17,6 +17,7 @@ #include #include "../MuEditor/UI/Console/MuEditorConsoleUI.h" #include "../MuEditor/UI/ItemTools/ItemToolWindow.h" +#include "UndoTarget.h" // Layout of the Open Editor strip over the game while the editor is closed. The // pixel sizes are multiplied by the editor UI scale so the strip grows with it @@ -202,7 +203,7 @@ void CMuEditorUI::RenderToolWindowBar() void CMuEditorUI::RenderBar(MuEditor::ToolWindows& windows) { RenderWindowsMenu(windows, MuEditor::PlatformWindows::WindowOf(ImGui::GetWindowViewport())); - MuEditor::Items::ToolWindow::RenderUndoRedo(); + MuEditor::UndoTargets::RenderBarButtons(MuEditor::Items::ToolWindow::GetUndoTarget()); RenderUIScale(); RenderLanguageSelector(); } @@ -228,6 +229,7 @@ void CMuEditorUI::RenderWindowsMenu(MuEditor::ToolWindows& windows, SDL_Window* ImGui::MenuItem(I18N::Editor::ItemTranslations, nullptr, &windows.itemTranslations); ImGui::EndMenu(); } + ImGui::MenuItem(I18N::Editor::ServerList, nullptr, &windows.serverList); ImGui::MenuItem("Skill Editor", nullptr, &windows.skillEditor); ImGui::MenuItem("Dev Editor", nullptr, &windows.devEditor); ImGui::MenuItem("Map Editor", nullptr, &windows.mapEditor); diff --git a/src/MuEditor/UI/Common/MuEditorWindowIds.h b/src/MuEditor/UI/Common/MuEditorWindowIds.h index 801a22c6ec..51b04a82d6 100644 --- a/src/MuEditor/UI/Common/MuEditorWindowIds.h +++ b/src/MuEditor/UI/Common/MuEditorWindowIds.h @@ -25,6 +25,7 @@ inline constexpr char SkillEditor[] = "###SkillEditor"; inline constexpr char DevEditor[] = "###DevEditor"; inline constexpr char EffectBrowser[] = "###EffectBrowser"; inline constexpr char ItemTranslations[] = "###ItemTranslations"; +inline constexpr char ServerList[] = "###ServerList"; // Titles that are not translated: the whole title is the name. inline constexpr char MapEditor[] = "Map Editor"; inline constexpr char Console[] = "Console"; diff --git a/src/MuEditor/UI/Common/UndoTarget.cpp b/src/MuEditor/UI/Common/UndoTarget.cpp new file mode 100644 index 0000000000..da0669c7c1 --- /dev/null +++ b/src/MuEditor/UI/Common/UndoTarget.cpp @@ -0,0 +1,55 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "UndoTarget.h" + +#include "I18N/All.h" +#include "imgui.h" +#include "imgui_internal.h" + +namespace MuEditor::UndoTargets +{ +namespace +{ +UndoTarget* g_activeTarget = nullptr; + +void RenderButton(const char* label, const char* keys, bool enabled, const std::string& step, void (UndoTarget::*act)(), + UndoTarget& target) +{ + ImGui::BeginDisabled(!enabled); + if (ImGui::Button(label)) + { + (target.*act)(); + } + if (enabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + { + ImGui::SetTooltip("%s (%s)", step.c_str(), keys); + } + ImGui::EndDisabled(); +} +} // namespace + +void Activate(UndoTarget& target) +{ + g_activeTarget = ⌖ +} + +void RenderBarButtons(UndoTarget& fallback) +{ + const bool activeIsOpen = g_activeTarget != nullptr && g_activeTarget->IsOpen(); + UndoTarget& target = activeIsOpen ? *g_activeTarget : fallback; + // A click leaves the focus in the tool, where Ctrl+Z and Ctrl+Y work. + ImGui::PushItemFlag(ImGuiItemFlags_NoFocus, true); + const bool canUndo = target.CanUndo(); + RenderButton(I18N::Editor::Undo, "Ctrl+Z", canUndo, canUndo ? target.GetUndoLabel() : std::string(), + &UndoTarget::Undo, target); + ImGui::SameLine(); + const bool canRedo = target.CanRedo(); + RenderButton(I18N::Editor::Redo, "Ctrl+Y", canRedo, canRedo ? target.GetRedoLabel() : std::string(), + &UndoTarget::Redo, target); + ImGui::PopItemFlag(); +} +} // namespace MuEditor::UndoTargets + +#endif // _EDITOR diff --git a/src/MuEditor/UI/Common/UndoTarget.h b/src/MuEditor/UI/Common/UndoTarget.h new file mode 100644 index 0000000000..0770065458 --- /dev/null +++ b/src/MuEditor/UI/Common/UndoTarget.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef _EDITOR + +#include + +namespace MuEditor +{ +// A tool with an undo history of its own (the item tools, the Server List +// window). The editor bar's Undo and Redo act on the tool the user worked in +// last. +class UndoTarget +{ +public: + // Whether the tool's window is open: a closed tool leaves the bar to the + // fallback (the item tools). + virtual bool IsOpen() const = 0; + virtual bool CanUndo() const = 0; + virtual bool CanRedo() const = 0; + // What the next Undo or Redo would do, for the buttons' tooltips. + virtual std::string GetUndoLabel() const = 0; + virtual std::string GetRedoLabel() const = 0; + virtual void Undo() = 0; + virtual void Redo() = 0; + +protected: + ~UndoTarget() = default; +}; + +namespace UndoTargets +{ +// A tool window calls this while it has the focus: the bar's Undo and Redo then +// act on its tool, also after the focus went elsewhere. +void Activate(UndoTarget& target); + +// The editor bar's Undo and Redo, for the tool worked in last, or for +// `fallback` before any was or while that tool is closed. A click does not take +// the focus from the tool. +void RenderBarButtons(UndoTarget& fallback); +} // namespace UndoTargets +} // namespace MuEditor + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/ItemToolWindow.cpp b/src/MuEditor/UI/ItemTools/ItemToolWindow.cpp index 288a1e99dc..c5f41ae3e0 100644 --- a/src/MuEditor/UI/ItemTools/ItemToolWindow.cpp +++ b/src/MuEditor/UI/ItemTools/ItemToolWindow.cpp @@ -11,6 +11,7 @@ #include "../MuEditor/Core/MuEditorCore.h" #include "../MuEditor/UI/Common/MuEditorLayout.h" #include "../MuEditor/UI/Common/MuEditorWindowIds.h" +#include "../MuEditor/UI/Common/UndoTarget.h" #include "../MuEditor/UI/Console/MuEditorConsoleUI.h" #include "Core/Text/Utf8.h" #include "Data/GameData/ItemData/ItemDatabase.h" @@ -45,7 +46,7 @@ void LogRefusal() } } -void Undo() +void UndoItemEdit() { if (g_ItemEdits.GetHistory().CanUndo() && !g_ItemEdits.Undo()) { @@ -53,7 +54,7 @@ void Undo() } } -void Redo() +void RedoItemEdit() { if (g_ItemEdits.GetHistory().CanRedo() && !g_ItemEdits.Redo()) { @@ -61,6 +62,42 @@ void Redo() } } +// The item edit history, for the editor bar's Undo and Redo. +class ItemUndoTarget final : public UndoTarget +{ +public: + // The fallback of the bar: item edits stay undoable with the item tools + // closed, as they always were. + bool IsOpen() const override + { + return true; + } + bool CanUndo() const override + { + return g_ItemEdits.GetHistory().CanUndo(); + } + bool CanRedo() const override + { + return g_ItemEdits.GetHistory().CanRedo(); + } + std::string GetUndoLabel() const override + { + return g_ItemEdits.GetHistory().GetUndoLabel(); + } + std::string GetRedoLabel() const override + { + return g_ItemEdits.GetHistory().GetRedoLabel(); + } + void Undo() override + { + UndoItemEdit(); + } + void Redo() override + { + RedoItemEdit(); + } +}; + // Only while the keyboard is the editor's and this window has the focus: ImGui // keeps the focus in the window when the game window takes the keyboard back // (Alt+Tab with the editor inside the game window). A text field keeps its own @@ -73,11 +110,11 @@ void HandleUndoKeys() } if (ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiKey_Z)) { - Undo(); + UndoItemEdit(); } if (ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiKey_Y) || ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z)) { - Redo(); + RedoItemEdit(); } } } // namespace @@ -92,6 +129,11 @@ bool Begin(const char* title, const char* windowId, bool* open) { return false; } + // Before the bar: in this window's own bar it shows the item undo then. + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + { + UndoTargets::Activate(GetUndoTarget()); + } MuEditor::Layout::RenderToolWindowBar(); if (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByPopup | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) || @@ -108,34 +150,10 @@ void End() ImGui::End(); } -void RenderUndoRedo() +UndoTarget& GetUndoTarget() { - // In the editor's bar: a click leaves the focus in the item tool, where - // Ctrl+Z and Ctrl+Y undo and redo. - ImGui::PushItemFlag(ImGuiItemFlags_NoFocus, true); - const ItemEditHistory& history = g_ItemEdits.GetHistory(); - ImGui::BeginDisabled(!history.CanUndo()); - if (ImGui::Button(I18N::Editor::Undo)) - { - Undo(); - } - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && history.CanUndo()) - { - ImGui::SetTooltip("%s (Ctrl+Z)", history.GetUndoLabel().c_str()); - } - ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::BeginDisabled(!history.CanRedo()); - if (ImGui::Button(I18N::Editor::Redo)) - { - Redo(); - } - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && history.CanRedo()) - { - ImGui::SetTooltip("%s (Ctrl+Y)", history.GetRedoLabel().c_str()); - } - ImGui::EndDisabled(); - ImGui::PopItemFlag(); + static ItemUndoTarget target; + return target; } bool RenderSelectedItem(int itemType) diff --git a/src/MuEditor/UI/ItemTools/ItemToolWindow.h b/src/MuEditor/UI/ItemTools/ItemToolWindow.h index 25ec31d60a..64185d3d80 100644 --- a/src/MuEditor/UI/ItemTools/ItemToolWindow.h +++ b/src/MuEditor/UI/ItemTools/ItemToolWindow.h @@ -2,6 +2,11 @@ #ifdef _EDITOR +namespace MuEditor +{ +class UndoTarget; +} + // What every item tool window does: where it docks, that the editor counts // the mouse over it, the undo and redo keys, and the line that names the // selected item. @@ -12,9 +17,9 @@ namespace MuEditor::Items::ToolWindow bool Begin(const char* title, const char* windowId, bool* open); void End(); -// The undo and redo buttons with what they would undo or redo, for the -// editor's bar; a click does not take the focus from the item tool. -void RenderUndoRedo(); +// The item edit history, which the editor bar's Undo and Redo act on while +// an item tool is the tool worked in last (UndoTargets). +UndoTarget& GetUndoTarget(); // "Kris (0,0)" in the UI language, with what tells it apart from items of the // same name (ItemNamesakes), or "Select an item"; false when there is no item diff --git a/src/MuEditor/UI/ServerList/MuServerListUI.cpp b/src/MuEditor/UI/ServerList/MuServerListUI.cpp new file mode 100644 index 0000000000..b90dc70797 --- /dev/null +++ b/src/MuEditor/UI/ServerList/MuServerListUI.cpp @@ -0,0 +1,627 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "MuServerListUI.h" + +#include "../MuEditor/Core/MuEditorCore.h" +#include "../MuEditor/UI/Common/KeyboardSelection.h" +#include "../MuEditor/UI/Common/MuEditorLayout.h" +#include "../MuEditor/UI/Common/MuEditorWindowIds.h" +#include "../MuEditor/UI/ItemTools/ItemTextInput.h" +#include "Data/GameData/ServerListData/ServerListJson.h" +#include "I18N/All.h" +#include "Network/Server/ServerListManager.h" +#include "Scenes/SceneCore.h" +#include "UI/Windows/ServerSelWin.h" +#include "imgui.h" + +#include +#include +#include +#include + +using Data::ServerList::ChannelType; +using Data::ServerList::ServerGroup; + +namespace +{ +// Pixel widths before the editor UI scale. +constexpr float GroupListWidth = 220.0f; +constexpr float IdFieldWidth = 120.0f; +constexpr float TextFieldWidth = 320.0f; +constexpr float TypeComboWidth = 140.0f; +constexpr int ChannelTableColumns = 4; +constexpr ImVec4 ErrorColor{1.0f, 0.4f, 0.4f, 1.0f}; + +const char* GetChannelTypeLabel(ChannelType type) +{ + switch (type) + { + case ChannelType::NonPvp: + return I18N::Editor::NonPvP; + case ChannelType::GoldPvp: + return I18N::Editor::GoldPvP; + case ChannelType::Gold: + return I18N::Editor::Gold; + case ChannelType::Normal: + default: + return I18N::Editor::Normal; + } +} + +// A grey line that wraps at the window's edge. +void RenderNote(const char* text) +{ + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", text); + ImGui::PopTextWrapPos(); +} + +void RenderError(const char* text) +{ + ImGui::PushStyleColor(ImGuiCol_Text, ErrorColor); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(text); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); +} + +bool BeginChannelTable() +{ + constexpr ImGuiTableFlags TableFlags = + ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingFixedFit; + if (!ImGui::BeginTable("##Channels", ChannelTableColumns, TableFlags)) + { + return false; + } + ImGui::TableSetupColumn(I18N::Editor::Channel); + ImGui::TableSetupColumn(I18N::Editor::Type); + ImGui::TableSetupColumn(I18N::Editor::OnTheLoginScreen); + ImGui::TableSetupColumn(I18N::Editor::ConnectServer); + ImGui::TableHeadersRow(); + return true; +} + +bool RenderChannelTypeCombo(ChannelType& type) +{ + ImGui::SetNextItemWidth(TypeComboWidth * g_MuEditorCore.GetUIScale()); + if (!ImGui::BeginCombo("##Type", GetChannelTypeLabel(type))) + { + return false; + } + bool changed = false; + for (int value = 0; value < Data::ServerList::ChannelTypeCount; ++value) + { + const ChannelType option = static_cast(value); + if (ImGui::Selectable(GetChannelTypeLabel(option), option == type) && option != type) + { + type = option; + changed = true; + } + } + ImGui::EndCombo(); + return changed; +} + +// One channel: its number, type, name on the login screen and the load the +// connect server sent for it (sentChannels: the group's, or nullptr). +bool RenderChannelRow(ServerGroup& group, int channel, const std::map* sentChannels) +{ + ImGui::PushID(channel); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("%d", channel + 1); + + ImGui::TableNextColumn(); + ChannelType& type = group.channels[static_cast(channel)]; + const bool changed = RenderChannelTypeCombo(type); + + ImGui::TableNextColumn(); + ImGui::TextDisabled("%s-%d%s", group.name.c_str(), channel + 1, Data::ServerList::GetChannelSuffix(type)); + + ImGui::TableNextColumn(); + if (sentChannels != nullptr) + { + const auto load = sentChannels->find(channel); + if (load != sentChannels->end()) + { + ImGui::TextUnformatted(I18N::Format(I18N::Editor::Sent0Load, {std::to_string(load->second)}).c_str()); + } + } + ImGui::PopID(); + return changed; +} + +CMuServerListUI::SentChannels FindSentChannels() +{ + CMuServerListUI::SentChannels sent; + for (const CServerListManager::ReceivedServer& server : g_ServerListManager->GetReceivedServers()) + { + sent[server.connectIndex / Data::ServerList::MaxChannelsPerGroup] + [server.connectIndex % Data::ServerList::MaxChannelsPerGroup] = server.percent; + } + return sent; +} + +std::string ListServerIds(const std::map& channels, int groupId) +{ + std::string text; + for (const auto& [channel, load] : channels) + { + text += (text.empty() ? "" : ", ") + std::to_string(groupId * Data::ServerList::MaxChannelsPerGroup + channel); + } + return text; +} +} // namespace + +CMuServerListUI& CMuServerListUI::GetInstance() +{ + static CMuServerListUI instance; + return instance; +} + +void CMuServerListUI::Render(bool* open) +{ + if (!m_loaded) + { + LoadFile(); + } + if (!BeginWindow(open)) + { + return; + } + if (m_sentVersion != g_ServerListManager->GetReceivedServersVersion()) + { + m_sent = FindSentChannels(); + m_sentVersion = g_ServerListManager->GetReceivedServersVersion(); + } + RenderToolbar(); + RenderStatus(); + ImGui::Separator(); + const bool changed = RenderPanes(); + ImGui::End(); + AfterEdits(changed); +} + +bool CMuServerListUI::BeginWindow(bool* open) +{ + char title[MuEditor::WindowIds::TitleCapacity]; + MuEditor::Layout::PrepareToolWindow(); + if (!ImGui::Begin( + MuEditor::WindowIds::Title(title, I18N::Editor::ServerList, MuEditor::WindowIds::ServerList), open, + ImGuiWindowFlags_NoCollapse | MuEditor::Layout::ToolWindowBarFlags(MuEditor::WindowIds::ServerList))) + { + ImGui::End(); + return false; + } + // Before the bar: in this window's own bar it shows the Server List's undo then. + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + { + MuEditor::UndoTargets::Activate(*this); + } + MuEditor::Layout::RenderToolWindowBar(); + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem)) + { + g_MuEditorCore.SetHoveringUI(true); + } + HandleUndoKeys(); + return true; +} + +bool CMuServerListUI::RenderPanes() +{ + bool changed = false; + const ImVec2 listSize(GroupListWidth * g_MuEditorCore.GetUIScale(), 0.0f); + if (ImGui::BeginChild("##Groups", listSize, ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeX)) + { + changed |= RenderGroupList(); + changed |= RenderListButtons(); + changed |= RenderGroupsNotInList(); + } + ImGui::EndChild(); + ImGui::SameLine(); + if (ImGui::BeginChild("##Group")) + { + changed |= RenderSelectedGroup(); + } + ImGui::EndChild(); + return changed; +} + +void CMuServerListUI::AfterEdits(bool changed) +{ + if (changed) + { + m_uncommitted = true; + ShowInGame(); + } + // A text field changes the groups with each key; its whole edit is one step. + if (m_uncommitted && !ImGui::IsAnyItemActive()) + { + m_edits.Commit(); + m_uncommitted = false; + } +} + +void CMuServerListUI::LoadFile() +{ + Data::ServerList::ServerListLoadResult loaded = + Data::ServerList::LoadServerListFile(Data::ServerList::ServerListFile); + m_edits.Reset(std::move(loaded.groups), !Data::Items::HasErrors(loaded.issues)); + m_issues = std::move(loaded.issues); + m_status.clear(); + m_statusIsError = false; + m_loaded = true; + m_uncommitted = false; + m_takenId = -1; + Select(m_edits.GetGroups().empty() ? -1 + : std::clamp(m_selected, 0, static_cast(m_edits.GetGroups().size()) - 1)); + ShowInGame(); +} + +void CMuServerListUI::Save() +{ + if (!m_edits.IsComplete()) + { + return; + } + m_edits.Commit(); + m_issues.clear(); + if (!Data::ServerList::SaveServerListFile(Data::ServerList::ServerListFile, m_edits.GetGroups(), m_issues)) + { + m_status = I18N::Editor::NotSavedSeeTheProblemsBelow; + m_statusIsError = true; + return; + } + m_edits.MarkSaved(); + m_status = I18N::Format(I18N::Editor::SavedTo0, {Data::ServerList::ServerListFile}); + m_statusIsError = false; +} + +void CMuServerListUI::RenderToolbar() +{ + // A save after a read with errors would remove the groups the read left out. + ImGui::BeginDisabled(!m_edits.IsComplete()); + if (ImGui::Button(I18N::Editor::Save)) + { + Save(); + } + ImGui::EndDisabled(); + if (!m_edits.IsComplete() && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + { + ImGui::SetTooltip("%s", I18N::Editor::SaveOffAfterReadErrors); + } + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Revert)) + { + LoadFile(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip( + "%s", + I18N::Format(I18N::Editor::Reads0AgainUnsavedChangesAreLost, {Data::ServerList::ServerListFile}).c_str()); + } + if (m_edits.HasUnsavedChanges()) + { + ImGui::SameLine(); + ImGui::TextUnformatted(I18N::Editor::UnsavedChanges); + } +} + +bool CMuServerListUI::IsOpen() const +{ + return g_MuEditorCore.GetToolWindows().serverList; +} + +bool CMuServerListUI::CanUndo() const +{ + return m_edits.CanUndo() || m_uncommitted; +} + +bool CMuServerListUI::CanRedo() const +{ + return m_edits.CanRedo(); +} + +std::string CMuServerListUI::GetUndoLabel() const +{ + return m_edits.GetUndoLabel(); +} + +std::string CMuServerListUI::GetRedoLabel() const +{ + return m_edits.GetRedoLabel(); +} + +void CMuServerListUI::Undo() +{ + m_edits.Undo(); + AfterHistoryStep(); +} + +void CMuServerListUI::Redo() +{ + m_edits.Redo(); + AfterHistoryStep(); +} + +void CMuServerListUI::AfterHistoryStep() +{ + m_uncommitted = false; + m_takenId = -1; + Select(std::min(m_selected, static_cast(m_edits.GetGroups().size()) - 1)); + ShowInGame(); +} + +void CMuServerListUI::HandleUndoKeys() +{ + // Only while the keyboard is the editor's: ImGui keeps the focus here when + // the game takes the keyboard back, e.g. to type into the login screen. A + // text field being edited takes the keys first. + if (!g_MuEditorCore.IsKeyboardWithEditor()) + { + return; + } + if (CanUndo() && ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiKey_Z)) + { + Undo(); + } + if (CanRedo() && + (ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiKey_Y) || ImGui::Shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z))) + { + Redo(); + } +} + +void CMuServerListUI::RenderStatus() +{ + RenderNote(I18N::Format(I18N::Editor::ServerListOrderNote, {Data::ServerList::ServerListFile}).c_str()); + if (!m_status.empty()) + { + if (m_statusIsError) + { + RenderError(m_status.c_str()); + } + else + { + ImGui::TextUnformatted(m_status.c_str()); + } + } + if (!m_edits.IsComplete()) + { + RenderError(I18N::Editor::SaveOffAfterReadErrors); + } + if (m_issues.empty()) + { + return; + } + ImGui::TextUnformatted(I18N::Format(I18N::Editor::ProblemsIn0, {Data::ServerList::ServerListFile}).c_str()); + for (const Data::Items::ItemDataIssue& issue : m_issues) + { + const std::string line = issue.field + ": " + issue.message; + if (issue.severity == Data::Items::ItemDataIssueSeverity::Error) + { + RenderError(line.c_str()); + } + else + { + RenderNote(line.c_str()); + } + } +} + +bool CMuServerListUI::RenderGroupList() +{ + const std::vector& groups = m_edits.GetGroups(); + for (int index = 0; index < static_cast(groups.size()); ++index) + { + const ServerGroup& group = groups[index]; + ImGui::PushID(index); + const std::string label = (group.name.empty() ? std::string("?") : group.name) + " (" + + std::to_string(group.id) + ")" + (group.testServer ? " *" : ""); + // The name is drawn as plain text: as the row's label, a "##" in it + // would start the ImGui ID and hide the rest. + const ImVec2 textPosition = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##Group", index == m_selected, MuEditor::KeyboardSelection::RowFlags)) + { + Select(index); + } + ImGui::GetWindowDrawList()->AddText(textPosition, ImGui::GetColorU32(ImGuiCol_Text), label.c_str()); + ImGui::PopID(); + } + return false; +} + +bool CMuServerListUI::RenderListButtons() +{ + bool changed = false; + if (ImGui::Button(I18N::Editor::Add)) + { + if (const std::optional added = m_edits.AddGroup(I18N::Editor::NewGroup)) + { + Select(static_cast(*added)); + changed = true; + } + } + ImGui::SameLine(); + const bool hasSelection = m_selected >= 0; + ImGui::BeginDisabled(!hasSelection); + if (ImGui::Button(I18N::Editor::Remove)) + { + m_edits.RemoveGroup(static_cast(m_selected)); + Select(std::min(m_selected, static_cast(m_edits.GetGroups().size()) - 1)); + changed = true; + } + ImGui::EndDisabled(); + ImGui::BeginDisabled(!hasSelection || m_selected == 0); + if (ImGui::Button(I18N::Editor::MoveUp) && m_edits.MoveGroup(static_cast(m_selected), -1)) + { + Select(m_selected - 1); + changed = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!hasSelection || m_selected + 1 >= static_cast(m_edits.GetGroups().size())); + if (ImGui::Button(I18N::Editor::MoveDown) && m_edits.MoveGroup(static_cast(m_selected), 1)) + { + Select(m_selected + 1); + changed = true; + } + ImGui::EndDisabled(); + return changed; +} + +bool CMuServerListUI::RenderGroupsNotInList() +{ + bool headerShown = false; + bool changed = false; + for (const auto& [groupId, channels] : m_sent) + { + if (m_edits.IsIdUsed(groupId)) + { + continue; + } + if (!headerShown) + { + ImGui::Separator(); + RenderNote(I18N::Editor::GroupsTheListDoesNotHave); + headerShown = true; + } + ImGui::PushID(groupId); + ImGui::TextUnformatted( + I18N::Format(I18N::Editor::Group0Servers1, {std::to_string(groupId), ListServerIds(channels, groupId)}) + .c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton(I18N::Editor::Add)) + { + if (const std::optional added = m_edits.AddGroup(I18N::Editor::NewGroup, groupId)) + { + Select(static_cast(*added)); + changed = true; + } + } + ImGui::PopID(); + } + return changed; +} + +bool CMuServerListUI::RenderSelectedGroup() +{ + std::vector& groups = m_edits.EditGroups(); + if (m_selected < 0 || m_selected >= static_cast(groups.size())) + { + ImGui::TextDisabled("%s", I18N::Editor::PickAGroupInTheList); + return false; + } + ServerGroup& group = groups[static_cast(m_selected)]; + bool changed = RenderGroupId(group); + changed |= RenderGroupFields(group); + ImGui::Separator(); + changed |= RenderChannels(group); + return changed; +} + +bool CMuServerListUI::RenderGroupId(ServerGroup& group) +{ + int id = group.id; + ImGui::SetNextItemWidth(IdFieldWidth * g_MuEditorCore.GetUIScale()); + bool changed = false; + // Typed only: a step button would stop at an ID another group has. + if (ImGui::InputInt(I18N::Editor::GroupID, &id, 0, 0)) + { + id = std::clamp(id, 0, Data::ServerList::MaxServerGroupId); + // An ID another group has stays with it; the field says so. + const bool taken = id != group.id && m_edits.IsIdUsed(id); + m_takenId = taken ? id : -1; + changed = m_takenId < 0 && id != group.id; + if (changed) + { + group.id = id; + } + } + const int firstServer = group.id * Data::ServerList::MaxChannelsPerGroup; + ImGui::SameLine(); + ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::Servers0To1, + {std::to_string(firstServer), + std::to_string(firstServer + Data::ServerList::MaxChannelsPerGroup - 1)}) + .c_str()); + if (m_takenId >= 0) + { + RenderError(I18N::Format(I18N::Editor::AnotherGroupHasTheID0, {std::to_string(m_takenId)}).c_str()); + } + return changed; +} + +bool CMuServerListUI::RenderGroupFields(ServerGroup& group) +{ + const float scale = g_MuEditorCore.GetUIScale(); + ImGui::SetNextItemWidth(TextFieldWidth * scale); + bool changed = false; + if (MuEditor::Items::InputString(I18N::Editor::Name, group.name)) + { + Data::ServerList::CutToUtf16Length(group.name, Data::ServerList::MaxServerGroupNameLength); + changed = true; + } + if (group.name.empty()) + { + RenderError(I18N::Editor::AGroupNeedsAName); + } + ImGui::SetNextItemWidth(TextFieldWidth * scale); + if (MuEditor::Items::InputString(I18N::Editor::Description, group.description)) + { + Data::ServerList::CutToUtf16Length(group.description, Data::ServerList::MaxServerGroupDescriptionLength); + changed = true; + } + RenderNote(I18N::Editor::DescriptionNote); + changed |= ImGui::Checkbox(I18N::Editor::TestServer, &group.testServer); + RenderNote(I18N::Editor::TestServerNote); + return changed; +} + +bool CMuServerListUI::RenderChannels(ServerGroup& group) +{ + if (!BeginChannelTable()) + { + return false; + } + const auto sentGroup = m_sent.find(group.id); + const std::map* sentChannels = sentGroup != m_sent.end() ? &sentGroup->second : nullptr; + bool changed = false; + for (int channel = 0; channel < Data::ServerList::MaxChannelsPerGroup; ++channel) + { + changed |= RenderChannelRow(group, channel, sentChannels); + } + ImGui::EndTable(); + return changed; +} + +void CMuServerListUI::ShowInGame() +{ + g_ServerListManager->SetServerGroups(m_edits.GetGroups()); + // Only the login screen shows the groups; elsewhere the next list the + // connect server sends gets the new names. + if (SceneFlag != LOG_IN_SCENE) + { + return; + } + // The login screen knows its open group by its button, which a group + // moved or removed in the list changes; it is found again by its ID. + const int openGroupId = g_ServerSelWin.GetOpenGroupId(); + g_ServerListManager->RebuildServerGroups(); + // Also while the server select window is hidden (after a server was + // picked): it keeps a pointer to the open group, which the rebuild deleted. + g_ServerSelWin.ShowRebuiltGroups(openGroupId); +} + +void CMuServerListUI::Select(int index) +{ + if (index != m_selected) + { + m_takenId = -1; + } + m_selected = index; +} + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ServerList/MuServerListUI.h b/src/MuEditor/UI/ServerList/MuServerListUI.h new file mode 100644 index 0000000000..1d63a78f44 --- /dev/null +++ b/src/MuEditor/UI/ServerList/MuServerListUI.h @@ -0,0 +1,86 @@ +#pragma once + +#ifdef _EDITOR + +#include "ServerListEdits.h" + +#include "../MuEditor/UI/Common/UndoTarget.h" +#include "Data/GameData/ItemData/ItemDataIssue.h" + +#include +#include +#include + +// The Server List window: the server groups of the login screen +// (Data/Local/ServerList.json), their names, order and channel types. The login +// screen shows each change right away; Save writes the file. The editor bar's +// Undo and Redo act on its changes while it is the tool worked in last. +class CMuServerListUI final : public MuEditor::UndoTarget +{ +public: + // The load of each server the connect server last sent, by group ID and + // channel number - 1. + using SentChannels = std::map>; + + static CMuServerListUI& GetInstance(); + + void Render(bool* open); + + // MuEditor::UndoTarget + bool IsOpen() const override; + bool CanUndo() const override; + bool CanRedo() const override; + std::string GetUndoLabel() const override; + std::string GetRedoLabel() const override; + void Undo() override; + void Redo() override; + +private: + // Begins the window with the editor's bar; false (and ended) while hidden. + bool BeginWindow(bool* open); + // The group list and the selected group; true when the groups changed. + bool RenderPanes(); + // After the widgets: the login screen shows a change, and a finished edit + // becomes one undo step. + void AfterEdits(bool changed); + void LoadFile(); + void Save(); + // Ctrl+Z and Ctrl+Y (or Ctrl+Shift+Z) while this window has the focus. + void HandleUndoKeys(); + // After an undo or redo: the selection stays in the list, and the login + // screen shows the groups. + void AfterHistoryStep(); + void RenderToolbar(); + void RenderStatus(); + // Each of these that changes the groups returns true. + bool RenderGroupList(); + bool RenderListButtons(); + bool RenderGroupsNotInList(); + bool RenderSelectedGroup(); + bool RenderGroupId(Data::ServerList::ServerGroup& group); + bool RenderGroupFields(Data::ServerList::ServerGroup& group); + bool RenderChannels(Data::ServerList::ServerGroup& group); + // The game names the servers of the connect server with the groups now. + void ShowInGame(); + void Select(int index); + + MuEditor::ServerList::ServerListEdits m_edits; + bool m_loaded = false; + // The fields changed the groups since the last undo step. + bool m_uncommitted = false; + int m_selected = -1; + // An ID typed for the selected group that another group has, or -1. + int m_takenId = -1; + // What the last read or save found, and what it said. + std::vector m_issues; + std::string m_status; + bool m_statusIsError = false; + // Found again when the connect server sent a new list + // (CServerListManager::GetReceivedServersVersion). + SentChannels m_sent; + int m_sentVersion = -1; +}; + +#define g_MuServerListUI CMuServerListUI::GetInstance() + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ServerList/ServerListEdits.cpp b/src/MuEditor/UI/ServerList/ServerListEdits.cpp new file mode 100644 index 0000000000..f7005e2279 --- /dev/null +++ b/src/MuEditor/UI/ServerList/ServerListEdits.cpp @@ -0,0 +1,147 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ServerListEdits.h" + +#include "ServerListStepLabel.h" + +#include +#include + +namespace MuEditor::ServerList +{ +void ServerListEdits::Reset(std::vector groups, bool complete) +{ + m_complete = complete; + m_groups = std::move(groups); + m_committed = m_groups; + m_saved = m_groups; + m_undo.clear(); + m_redo.clear(); + m_pendingLabel.clear(); +} + +void ServerListEdits::Commit() +{ + if (m_groups == m_committed) + { + m_pendingLabel.clear(); + return; + } + std::string label = GetPendingLabel(); + m_undo.push_back({std::move(m_committed), std::move(label)}); + if (m_undo.size() > MaxUndoSteps) + { + m_undo.erase(m_undo.begin()); + } + m_redo.clear(); + m_committed = m_groups; + m_pendingLabel.clear(); +} + +void ServerListEdits::Undo() +{ + Commit(); + if (m_undo.empty()) + { + return; + } + Step step = std::move(m_undo.back()); + m_undo.pop_back(); + m_redo.push_back({std::move(m_groups), std::move(step.label)}); + m_groups = std::move(step.groups); + m_committed = m_groups; +} + +void ServerListEdits::Redo() +{ + Commit(); + if (m_redo.empty()) + { + return; + } + Step step = std::move(m_redo.back()); + m_redo.pop_back(); + m_undo.push_back({std::move(m_groups), std::move(step.label)}); + m_groups = std::move(step.groups); + m_committed = m_groups; +} + +std::string ServerListEdits::GetUndoLabel() const +{ + if (m_groups != m_committed) + { + return GetPendingLabel(); + } + return m_undo.empty() ? std::string() : m_undo.back().label; +} + +std::string ServerListEdits::GetRedoLabel() const +{ + return m_redo.empty() ? std::string() : m_redo.back().label; +} + +std::string ServerListEdits::GetPendingLabel() const +{ + return m_pendingLabel.empty() ? StepLabel::DescribeFieldChange(m_committed, m_groups) : m_pendingLabel; +} + +std::optional ServerListEdits::AddGroup(const std::string& name, std::optional id) +{ + if (!id || IsIdUsed(*id)) + { + id = FindFreeId(); + } + if (!id) + { + return std::nullopt; + } + ServerGroup group; + group.id = *id; + group.name = name; + m_groups.push_back(std::move(group)); + m_pendingLabel = StepLabel::Add(name); + return m_groups.size() - 1; +} + +void ServerListEdits::RemoveGroup(size_t index) +{ + if (index < m_groups.size()) + { + m_pendingLabel = StepLabel::Remove(m_groups[index].name); + m_groups.erase(m_groups.begin() + static_cast(index)); + } +} + +bool ServerListEdits::MoveGroup(size_t index, int direction) +{ + const long long target = static_cast(index) + direction; + if (index >= m_groups.size() || target < 0 || target >= static_cast(m_groups.size())) + { + return false; + } + m_pendingLabel = StepLabel::Move(m_groups[index].name); + std::swap(m_groups[index], m_groups[static_cast(target)]); + return true; +} + +bool ServerListEdits::IsIdUsed(int id) const +{ + return std::any_of(m_groups.begin(), m_groups.end(), [id](const ServerGroup& group) { return group.id == id; }); +} + +std::optional ServerListEdits::FindFreeId() const +{ + for (int id = 0; id <= Data::ServerList::MaxServerGroupId; ++id) + { + if (!IsIdUsed(id)) + { + return id; + } + } + return std::nullopt; +} +} // namespace MuEditor::ServerList + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ServerList/ServerListEdits.h b/src/MuEditor/UI/ServerList/ServerListEdits.h new file mode 100644 index 0000000000..da572b737e --- /dev/null +++ b/src/MuEditor/UI/ServerList/ServerListEdits.h @@ -0,0 +1,107 @@ +#pragma once + +#ifdef _EDITOR + +#include "Data/GameData/ServerListData/ServerListGroup.h" + +#include +#include +#include +#include + +namespace MuEditor::ServerList +{ +using Data::ServerList::ServerGroup; + +// The server groups the Server List window changes: what the editor shows, +// what the file has, and an undo list for this session. +class ServerListEdits +{ +public: + static constexpr size_t MaxUndoSteps = 256; + + // The groups of the file, just read or saved: nothing to undo, nothing + // changed. `complete` is false when the read left groups out (errors). + void Reset(std::vector groups, bool complete = true); + + const std::vector& GetGroups() const + { + return m_groups; + } + // For the window's fields, which change the groups in place; Commit makes + // what they changed one undo step. + std::vector& EditGroups() + { + return m_groups; + } + // The groups changed since the last step become one undo step. + void Commit(); + + bool CanUndo() const + { + return !m_undo.empty(); + } + bool CanRedo() const + { + return !m_redo.empty(); + } + void Undo(); + void Redo(); + // What Undo or Redo would take back or do again ("Remove Helheim", + // "Valhalla: channel 3"); empty when there is nothing. + std::string GetUndoLabel() const; + std::string GetRedoLabel() const; + + // Whether the groups differ from the file. + bool HasUnsavedChanges() const + { + return m_groups != m_saved; + } + // The groups are what the file has now. + void MarkSaved() + { + m_saved = m_groups; + } + // Whether the groups are all the file has. After a read with errors they + // are not, and a save would remove the groups left out from the file. + bool IsComplete() const + { + return m_complete; + } + + // Adds a group named `name` with the smallest free ID, or `id` when given + // (and free), at the end of the list. Returns its index; nothing when no + // ID is free. + std::optional AddGroup(const std::string& name, std::optional id = std::nullopt); + void RemoveGroup(size_t index); + // Moves the group one place up (-1) or down (+1); false at the end of the list. + bool MoveGroup(size_t index, int direction); + + bool IsIdUsed(int id) const; + std::optional FindFreeId() const; + +private: + // The groups on the other side of a step, and what the step did. + struct Step + { + std::vector groups; + std::string label; + }; + + // What the groups changed since the last step would be called. + std::string GetPendingLabel() const; + + std::vector m_groups; + // The groups as the last undo step left them. + std::vector m_committed; + std::vector m_saved; + std::vector m_undo; + std::vector m_redo; + // Set by AddGroup, RemoveGroup and MoveGroup until the next Commit; a + // change of fields is described by comparing the groups instead. + std::string m_pendingLabel; + bool m_complete = true; +}; +} // namespace MuEditor::ServerList + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ServerList/ServerListStepLabel.cpp b/src/MuEditor/UI/ServerList/ServerListStepLabel.cpp new file mode 100644 index 0000000000..7b457f2997 --- /dev/null +++ b/src/MuEditor/UI/ServerList/ServerListStepLabel.cpp @@ -0,0 +1,78 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ServerListStepLabel.h" + +#include "I18N/All.h" + +#include + +namespace MuEditor::ServerList::StepLabel +{ +namespace +{ +using Data::ServerList::ServerGroup; + +// The first channel whose type differs, as its number; 0 when none does. +int FindChangedChannel(const ServerGroup& before, const ServerGroup& after) +{ + const auto changed = std::mismatch(before.channels.begin(), before.channels.end(), after.channels.begin()); + return changed.first == before.channels.end() + ? 0 + : static_cast(std::distance(before.channels.begin(), changed.first)) + 1; +} + +std::string DescribeGroupChange(const ServerGroup& before, const ServerGroup& after) +{ + const std::string& name = before.name.empty() ? after.name : before.name; + if (before.id != after.id) + { + return I18N::Format(I18N::Editor::StepID, {name}); + } + if (before.name != after.name) + { + return I18N::Format(I18N::Editor::StepName, {name}); + } + if (before.description != after.description) + { + return I18N::Format(I18N::Editor::StepDescription, {name}); + } + if (before.testServer != after.testServer) + { + return I18N::Format(I18N::Editor::StepTestServer, {name}); + } + return I18N::Format(I18N::Editor::StepChannel, {name, std::to_string(FindChangedChannel(before, after))}); +} +} // namespace + +std::string Add(const std::string& groupName) +{ + return I18N::Format(I18N::Editor::StepAdd, {groupName}); +} + +std::string Remove(const std::string& groupName) +{ + return I18N::Format(I18N::Editor::StepRemove, {groupName}); +} + +std::string Move(const std::string& groupName) +{ + return I18N::Format(I18N::Editor::StepMove, {groupName}); +} + +std::string DescribeFieldChange(std::span before, std::span after) +{ + const size_t count = std::min(before.size(), after.size()); + for (size_t index = 0; index < count; ++index) + { + if (before[index] != after[index]) + { + return DescribeGroupChange(before[index], after[index]); + } + } + return I18N::Editor::ServerList; +} +} // namespace MuEditor::ServerList::StepLabel + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ServerList/ServerListStepLabel.h b/src/MuEditor/UI/ServerList/ServerListStepLabel.h new file mode 100644 index 0000000000..2f2da9e0c4 --- /dev/null +++ b/src/MuEditor/UI/ServerList/ServerListStepLabel.h @@ -0,0 +1,23 @@ +#pragma once + +#ifdef _EDITOR + +#include "Data/GameData/ServerListData/ServerListGroup.h" + +#include +#include + +// What an undo step of the Server List window did, for the editor bar's +// tooltips: "Remove Helheim", "Valhalla: channel 3". +namespace MuEditor::ServerList::StepLabel +{ +std::string Add(const std::string& groupName); +std::string Remove(const std::string& groupName); +std::string Move(const std::string& groupName); +// A change of one group's fields, by the first group and field that differ; +// the group is named as it was before. +std::string DescribeFieldChange(std::span before, + std::span after); +} // namespace MuEditor::ServerList::StepLabel + +#endif // _EDITOR diff --git a/src/bin/Data/Local/ServerList.bmd b/src/bin/Data/Local/ServerList.bmd deleted file mode 100644 index 3993621209..0000000000 --- a/src/bin/Data/Local/ServerList.bmd +++ /dev/null @@ -1 +0,0 @@ -üÏý�£Ã�£Ç�Ï«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üϪÿ̨ÿͪýΫüÏ«üÏ«þÏÜÏýÏ㙣Ù¦ÆüÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ©ÿ̨ÿͪýΫüÏ«üÏ«þÏÜÏþÏæ•«Ì�½ÏüÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üÏ«üϨÿ̨ÿͪýΫüÏ«üÏ«þÏÜÏ \ No newline at end of file diff --git a/src/bin/Data/Local/ServerList.json b/src/bin/Data/Local/ServerList.json new file mode 100644 index 0000000000..1b738575c9 --- /dev/null +++ b/src/bin/Data/Local/ServerList.json @@ -0,0 +1,20 @@ +{ + "formatVersion": 1, + "groups": [ + { + "id": 0, + "name": "Valhalla", + "channels": {"1": "gold", "2": "gold", "3": "gold", "4": "gold", "5": "goldPvp", "6": "nonPvp", "7": "nonPvp", "8": "nonPvp"} + }, + { + "id": 1, + "name": "Helheim", + "channels": {"1": "gold", "2": "gold", "3": "gold", "4": "gold", "5": "goldPvp", "6": "nonPvp", "7": "nonPvp", "8": "nonPvp"} + }, + { + "id": 2, + "name": "Midgard", + "channels": {"1": "gold", "2": "gold", "3": "gold", "4": "gold", "5": "goldPvp", "6": "nonPvp", "7": "nonPvp", "8": "nonPvp"} + } + ] +} diff --git a/src/source/Data/GameData/ServerListData/ServerListGroup.h b/src/source/Data/GameData/ServerListData/ServerListGroup.h new file mode 100644 index 0000000000..3bcc5c2daa --- /dev/null +++ b/src/source/Data/GameData/ServerListData/ServerListGroup.h @@ -0,0 +1,67 @@ +#pragma once + +#include "Core/Globals/_define.h" + +#include +#include +#include + +namespace Data::ServerList +{ +// What the login screen writes after a channel's number, and its color there. +// The values are the ones the client always had: bit 0 is "no PvP", bit 1 "gold". +enum class ChannelType : std::uint8_t +{ + Normal = 0, // "Valhalla-1", gray + NonPvp = 1, // "Valhalla-1(Non-PVP)" + GoldPvp = 2, // "Valhalla-1(Gold PVP)", orange + Gold = 3, // "Valhalla-1(Gold)", orange +}; + +inline constexpr int ChannelTypeCount = 4; + +// What the login screen writes after "-" for the type. +constexpr const char* GetChannelSuffix(ChannelType type) +{ + switch (type) + { + case ChannelType::NonPvp: + return "(Non-PVP)"; + case ChannelType::GoldPvp: + return "(Gold PVP)"; + case ChannelType::Gold: + return "(Gold)"; + case ChannelType::Normal: + default: + return ""; + } +} + +// A server sends its ID; the group is ID / MAX_SERVER_PER_GROUP and the +// channel ID % MAX_SERVER_PER_GROUP + 1. +inline constexpr int MaxChannelsPerGroup = MAX_SERVER_PER_GROUP; +// Server IDs are 16 bit. +inline constexpr int MaxServerGroupId = 0xFFFF / MAX_SERVER_PER_GROUP; +// In UTF-16 units, as the client's wide strings hold them on Windows (an emoji +// takes two): the name goes into fixed wide buffers, e.g. the screenshot +// message's 64 with the character name. 32 is also the width of the group +// buttons on the login screen. +inline constexpr int MaxServerGroupNameLength = 32; +inline constexpr int MaxServerGroupDescriptionLength = MAX_TEXT_LENGTH - 1; + +// One group of game servers on the login screen: its button, and the name +// its channels get. +struct ServerGroup +{ + int id = 0; + std::string name; // UTF-8 + std::string description; // UTF-8, shown below the channels of the selected group + // A test server group: the login screen shows only the first one, at its + // place in the list. + bool testServer = false; + // Index: channel number - 1. + std::array channels{}; + + bool operator==(const ServerGroup&) const = default; +}; +} // namespace Data::ServerList diff --git a/src/source/Data/GameData/ServerListData/ServerListJson.cpp b/src/source/Data/GameData/ServerListData/ServerListJson.cpp new file mode 100644 index 0000000000..87c01c2ced --- /dev/null +++ b/src/source/Data/GameData/ServerListData/ServerListJson.cpp @@ -0,0 +1,414 @@ +#include "stdafx.h" + +#include "ServerListJson.h" + +#include "Data/GameData/ItemData/ItemJsonCommon.h" + +#include +#include +#include +#include +#include + +namespace Data::ServerList +{ +namespace +{ +using Items::ItemDataIssue; +using Items::ItemDataIssueSeverity; +using Items::Json::OrderedJson; + +constexpr const char* GroupsKey = "groups"; +constexpr const char* IdKey = "id"; +constexpr const char* NameKey = "name"; +constexpr const char* DescriptionKey = "description"; +constexpr const char* TestServerKey = "testServer"; +constexpr const char* ChannelsKey = "channels"; +// A save writes this file first, then puts it in the old one's place. +constexpr const char* TemporaryExtension = ".tmp"; + +// In the order of the ChannelType values. +constexpr std::array ChannelTypeNames = {"normal", "nonPvp", "goldPvp", "gold"}; + +// The bits of a UTF-8 byte that tell whether it continues a character, and +// whether it starts one of four bytes. +constexpr unsigned char Utf8ContinuationMask = 0xC0; +constexpr unsigned char Utf8ContinuationBits = 0x80; +constexpr unsigned char Utf8FourByteMask = 0xF8; +constexpr unsigned char Utf8FourByteBits = 0xF0; +// A character outside the Basic Multilingual Plane takes two UTF-16 units. +constexpr size_t SurrogatePairUnits = 2; + +class IssueList +{ +public: + IssueList(const std::string& source, std::vector& issues) : m_source(source), m_issues(issues) {} + + void Error(const std::string& field, const std::string& message) + { + Items::Json::AddFileIssue(m_issues, m_source, ItemDataIssue::NoItem, field, message); + } + + void Warning(const std::string& field, const std::string& message) + { + m_issues.push_back( + {ItemDataIssueSeverity::Warning, m_source, ItemDataIssue::NoItem, ItemDataIssue::NoItem, field, message}); + } + +private: + const std::string& m_source; + std::vector& m_issues; +}; + +std::string GroupField(size_t index) +{ + return std::string(GroupsKey) + "[" + std::to_string(index) + "]"; +} + +std::string Field(const std::string& object, const char* key) +{ + return object + "." + key; +} + +bool IsContinuationByte(char byte) +{ + return (static_cast(byte) & Utf8ContinuationMask) == Utf8ContinuationBits; +} + +// The UTF-16 units of the character a UTF-8 lead byte starts: a four-byte +// character (most emoji) is outside the Basic Multilingual Plane. +size_t Utf16UnitsOf(char leadByte) +{ + return (static_cast(leadByte) & Utf8FourByteMask) == Utf8FourByteBits ? SurrogatePairUnits : 1; +} + +bool ReadId(const OrderedJson& json, const std::string& field, int& id, IssueList& issues) +{ + const auto value = json.find(IdKey); + long long number = 0; + if (value == json.end() || !value->is_number_integer() || !Items::Json::ReadWholeNumber(*value, number) || + number < 0 || number > MaxServerGroupId) + { + issues.Error(Field(field, IdKey), + "missing or not a whole number from 0 to " + std::to_string(MaxServerGroupId)); + return false; + } + id = static_cast(number); + return true; +} + +// A text of the group; one longer than maxUnits UTF-16 units is cut. +bool ReadText(const OrderedJson& json, const std::string& field, const char* key, size_t maxUnits, std::string& text, + IssueList& issues) +{ + const auto value = json.find(key); + if (value == json.end()) + { + return true; + } + if (!value->is_string()) + { + issues.Error(Field(field, key), "must be a text"); + return false; + } + text = value->get(); + if (CutToUtf16Length(text, maxUnits)) + { + issues.Warning(Field(field, key), + "longer than " + std::to_string(maxUnits) + " characters (an emoji counts as two), cut"); + } + return true; +} + +bool ReadName(const OrderedJson& json, const std::string& field, std::string& name, IssueList& issues) +{ + if (!ReadText(json, field, NameKey, MaxServerGroupNameLength, name, issues)) + { + return false; + } + if (name.empty()) + { + issues.Error(Field(field, NameKey), "missing or empty"); + return false; + } + return true; +} + +bool ReadTestServer(const OrderedJson& json, const std::string& field, bool& testServer, IssueList& issues) +{ + const auto value = json.find(TestServerKey); + if (value == json.end()) + { + return true; + } + if (!value->is_boolean()) + { + issues.Error(Field(field, TestServerKey), "must be true or false"); + return false; + } + testServer = value->get(); + return true; +} + +// "1" to "20"; nothing for anything else. +std::optional ParseChannelNumber(std::string_view text) +{ + int number = 0; + const char* end = text.data() + text.size(); + const auto [parsedEnd, error] = std::from_chars(text.data(), end, number); + if (error != std::errc{} || parsedEnd != end || number < 1 || number > MaxChannelsPerGroup) + { + return std::nullopt; + } + return number; +} + +bool ReadChannel(const std::string& key, const OrderedJson& value, const std::string& field, ServerGroup& group, + IssueList& issues) +{ + const std::optional number = ParseChannelNumber(key); + if (!number) + { + issues.Error(field, "a channel number is 1 to " + std::to_string(MaxChannelsPerGroup)); + return false; + } + const std::optional type = + value.is_string() ? FindChannelType(value.get()) : std::nullopt; + if (!type) + { + issues.Error(field, "must be normal, nonPvp, goldPvp or gold"); + return false; + } + group.channels[*number - 1] = *type; + return true; +} + +bool ReadChannels(const OrderedJson& json, const std::string& field, ServerGroup& group, IssueList& issues) +{ + const auto channels = json.find(ChannelsKey); + if (channels == json.end()) + { + return true; + } + if (!channels->is_object()) + { + issues.Error(Field(field, ChannelsKey), "must be an object of channel numbers and types"); + return false; + } + bool valid = true; + for (const auto& [key, value] : channels->items()) + { + valid = ReadChannel(key, value, Field(Field(field, ChannelsKey), key.c_str()), group, issues) && valid; + } + return valid; +} + +std::optional ReadGroup(const OrderedJson& json, const std::string& field, IssueList& issues) +{ + if (!json.is_object()) + { + issues.Error(field, "must be an object"); + return std::nullopt; + } + ServerGroup group; + bool valid = ReadId(json, field, group.id, issues); + valid = ReadName(json, field, group.name, issues) && valid; + valid = ReadText(json, field, DescriptionKey, MaxServerGroupDescriptionLength, group.description, issues) && valid; + valid = ReadTestServer(json, field, group.testServer, issues) && valid; + valid = ReadChannels(json, field, group, issues) && valid; + Items::Json::WarnAboutUnknownKeys(json, {IdKey, NameKey, DescriptionKey, TestServerKey, ChannelsKey}, field + ".", + [&](const std::string& unknown, const std::string& message) + { issues.Warning(unknown, message); }); + return valid ? std::optional(std::move(group)) : std::nullopt; +} + +// The login screen shows the first test server group only. +void WarnAboutMoreTestServers(const std::vector& groups, IssueList& issues) +{ + const auto first = + std::find_if(groups.begin(), groups.end(), [](const ServerGroup& group) { return group.testServer; }); + if (first == groups.end()) + { + return; + } + for (auto group = std::next(first); group != groups.end(); ++group) + { + if (group->testServer) + { + issues.Warning(TestServerKey, "group " + std::to_string(group->id) + + " is not shown: only the first test server group is, which is group " + + std::to_string(first->id)); + } + } +} + +OrderedJson WriteChannels(const ServerGroup& group) +{ + OrderedJson channels = OrderedJson::object(); + for (size_t index = 0; index < group.channels.size(); ++index) + { + if (group.channels[index] != ChannelType::Normal) + { + channels[std::to_string(index + 1)] = GetChannelTypeName(group.channels[index]); + } + } + return channels; +} + +OrderedJson WriteGroup(const ServerGroup& group) +{ + OrderedJson json = OrderedJson::object(); + json[IdKey] = group.id; + json[NameKey] = group.name; + if (!group.description.empty()) + { + json[DescriptionKey] = group.description; + } + if (group.testServer) + { + json[TestServerKey] = true; + } + OrderedJson channels = WriteChannels(group); + if (!channels.empty()) + { + json[ChannelsKey] = std::move(channels); + } + return json; +} +} // namespace + +bool CutToUtf16Length(std::string& text, size_t maxUnits) +{ + size_t units = 0; + for (size_t i = 0; i < text.size(); ++i) + { + if (IsContinuationByte(text[i])) + { + continue; + } + units += Utf16UnitsOf(text[i]); + if (units > maxUnits) + { + text.resize(i); + return true; + } + } + return false; +} + +const char* GetChannelTypeName(ChannelType type) +{ + return ChannelTypeNames[static_cast(type)]; +} + +std::optional FindChannelType(std::string_view name) +{ + const auto found = std::find(ChannelTypeNames.begin(), ChannelTypeNames.end(), name); + if (found == ChannelTypeNames.end()) + { + return std::nullopt; + } + return static_cast(std::distance(ChannelTypeNames.begin(), found)); +} + +void ReadServerListJson(std::string_view text, const std::string& source, std::vector& groups, + std::vector& issues) +{ + IssueList list(source, issues); + OrderedJson root; + if (!Items::Json::ReadFileVersion(text, source, ServerListFormatVersion, root, issues)) + { + return; + } + Items::Json::WarnAboutUnknownKeys(root, {Items::Json::Keys::FormatVersion, GroupsKey}, "", + [&](const std::string& unknown, const std::string& message) + { list.Warning(unknown, message); }); + const auto entries = root.find(GroupsKey); + if (entries == root.end() || !entries->is_array()) + { + list.Error(GroupsKey, "missing or not a list of server groups"); + return; + } + std::set ids; + for (size_t index = 0; index < entries->size(); ++index) + { + const std::string field = GroupField(index); + std::optional group = ReadGroup((*entries)[index], field, list); + if (!group) + { + continue; + } + if (!ids.insert(group->id).second) + { + list.Error(Field(field, IdKey), "group " + std::to_string(group->id) + " is in the list more than once"); + continue; + } + groups.push_back(std::move(*group)); + } + WarnAboutMoreTestServers(groups, list); +} + +std::string WriteServerListJson(std::span groups) +{ + OrderedJson entries = OrderedJson::array(); + for (const ServerGroup& group : groups) + { + entries.push_back(WriteGroup(group)); + } + OrderedJson root = OrderedJson::object(); + root[Items::Json::Keys::FormatVersion] = ServerListFormatVersion; + root[GroupsKey] = std::move(entries); + const std::string text = root.dump(Items::Json::Indent, ' ', false, OrderedJson::error_handler_t::replace); + return Items::Json::PutObjectsOnOneLine(text, ChannelsKey) + "\n"; +} + +ServerListLoadResult LoadServerListFile(const std::filesystem::path& file) +{ + ServerListLoadResult result; + const std::string source = file.generic_string(); + const std::optional text = Items::Json::ReadTextFile(file); + if (!text) + { + IssueList(source, result.issues).Error("", "could not be read"); + return result; + } + ReadServerListJson(*text, source, result.groups, result.issues); + return result; +} + +bool SaveServerListFile(const std::filesystem::path& file, std::span groups, + std::vector& issues) +{ + const std::string source = file.generic_string(); + const std::string text = WriteServerListJson(groups); + std::vector readBack; + ReadServerListJson(text, source, readBack, issues); + if (Items::HasErrors(issues)) + { + return false; + } + + std::filesystem::path temporaryFile = file; + temporaryFile += TemporaryExtension; + std::error_code error; + std::filesystem::create_directories(file.parent_path(), error); + std::ofstream stream(temporaryFile, std::ios::binary | std::ios::trunc); + stream << text; + // Closing flushes; checking only afterwards also catches a failing final write. + stream.close(); + if (!stream) + { + IssueList(source, issues).Error("", "could not be written"); + std::filesystem::remove(temporaryFile, error); + return false; + } + std::filesystem::rename(temporaryFile, file, error); + if (error) + { + IssueList(source, issues).Error("", "could not be replaced: " + error.message()); + std::filesystem::remove(temporaryFile, error); + return false; + } + return true; +} +} // namespace Data::ServerList diff --git a/src/source/Data/GameData/ServerListData/ServerListJson.h b/src/source/Data/GameData/ServerListData/ServerListJson.h new file mode 100644 index 0000000000..fb50db9b70 --- /dev/null +++ b/src/source/Data/GameData/ServerListData/ServerListJson.h @@ -0,0 +1,65 @@ +#pragma once + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ServerListData/ServerListGroup.h" + +#include +#include +#include +#include +#include +#include + +// The server groups of the login screen (Data/Local/ServerList.json): +// +// { "formatVersion": 1, +// "groups": [ +// { "id": 0, "name": "Valhalla", "channels": {"1": "gold", "5": "goldPvp", "6": "nonPvp"} }, +// { "id": 1, "name": "Helheim", "description": "PvP everywhere" }, +// { "id": 9, "name": "Test", "testServer": true } ] } +// +// The list order is the order of the buttons. "id" is the server ID / 20: the +// connect server's servers 0 to 19 are group 0, 20 to 39 group 1. A server the +// list has no group for is not shown. "channels" gives the type of a channel +// by its number (server ID % 20 + 1): normal (the default), nonPvp, goldPvp or +// gold. "description" and "testServer" are optional. +namespace Data::ServerList +{ +constexpr int ServerListFormatVersion = 1; + +// The file, relative to the client folder. +constexpr const char* ServerListFile = "Data/Local/ServerList.json"; + +// The name of a channel type in the file ("nonPvp"), and back. +const char* GetChannelTypeName(ChannelType type); +std::optional FindChannelType(std::string_view name); + +// Reads the groups of the file into `groups`, in their order. A group with an +// error is left out; a name or description that is too long is cut (a warning). +// Lengths are counted in UTF-16 units, as the client's wide strings hold them on +// Windows: an emoji counts as two. +void ReadServerListJson(std::string_view text, const std::string& source, std::vector& groups, + std::vector& issues); + +// The text of the file with these groups, in their order. +std::string WriteServerListJson(std::span groups); + +// Cuts UTF-8 text to at most maxUnits UTF-16 units, never inside a +// character; false when it is not longer. +bool CutToUtf16Length(std::string& text, size_t maxUnits); + +struct ServerListLoadResult +{ + std::vector groups; + std::vector issues; +}; + +// Reads the file; one that cannot be read is an error. +ServerListLoadResult LoadServerListFile(const std::filesystem::path& file); + +// Writes the groups to the file when they read back without errors; the +// problems found are added to `issues`. The old file stays when the new one +// cannot be written whole. +bool SaveServerListFile(const std::filesystem::path& file, std::span groups, + std::vector& issues); +} // namespace Data::ServerList diff --git a/src/source/Engine/Object/ZzzOpenData.cpp b/src/source/Engine/Object/ZzzOpenData.cpp index 2f1980b15a..d1cb012ace 100644 --- a/src/source/Engine/Object/ZzzOpenData.cpp +++ b/src/source/Engine/Object/ZzzOpenData.cpp @@ -370,7 +370,7 @@ void OpenPlayerTextures() } } -// The game cannot run with broken item or effect data: shows the errors and quits. +// The game cannot run with broken item, effect or server list data: shows the errors and quits. static void StopOnDataError(const char* title, const std::string& errorMessage) { const std::wstring message = Core::Text::FromUtf8(errorMessage); @@ -384,6 +384,17 @@ static void StopOnItemDataError(const std::string& errorMessage) StopOnDataError("Item data error", errorMessage); } +// Loads the server groups of the login screen (Data/Local/ServerList.json); +// errors stop the game. +static void OpenServerList() +{ + std::string errorMessage; + if (!g_ServerListManager->LoadServerListScript(errorMessage)) + { + StopOnDataError("Server list error", errorMessage); + } +} + // Loads the effect types (Data/Effects) and builds the effect registry from // their creation values; errors stop the game. static void OpenEffectTypeData() @@ -4392,7 +4403,7 @@ void OpenBasicData(HDC hDC) wchar_t Text[100]; - g_ServerListManager->LoadServerListScript(); + OpenServerList(); // NPC dialog text used to live in Data\Local\\Dialog_.bmd // and was loaded into g_DialogScript here. The text and answer labels diff --git a/src/source/Network/Server/ServerListManager.cpp b/src/source/Network/Server/ServerListManager.cpp index 6ad7aaee6d..d2e4fb866d 100644 --- a/src/source/Network/Server/ServerListManager.cpp +++ b/src/source/Network/Server/ServerListManager.cpp @@ -6,6 +6,21 @@ #include "ServerListManager.h" #include "I18N/All.h" +#include "Core/Text/Utf8.h" +#include "Data/DataHandler/DataIssueReport.h" +#include "Data/GameData/ServerListData/ServerListJson.h" + +#include +#include +#include + +namespace +{ +constexpr const char* ServerListName = "server list"; +// The bit of a channel type that turns PvP off. +constexpr BYTE NonPvpBit = 0x01; +} // namespace + CServerListManager::CServerListManager() { m_iTotalServer = 0; @@ -26,6 +41,10 @@ CServerListManager* CServerListManager::GetInstance() void CServerListManager::Release() { +#ifdef _EDITOR + m_receivedServers.clear(); + ++m_receivedServersVersion; +#endif auto iterServerGroup = m_mapServerGroup.begin(); for (; iterServerGroup != m_mapServerGroup.end(); iterServerGroup++) { @@ -36,60 +55,47 @@ void CServerListManager::Release() m_iTotalServer = 0; } -void CServerListManager::LoadServerListScript() +bool CServerListManager::LoadServerListScript(std::string& errorMessage) { - FILE* fp = ::_wfopen(L"Data\\Local\\ServerList.bmd", L"rb"); - - if (fp == NULL) + const std::filesystem::path file = Data::ServerList::ServerListFile; + Data::ServerList::ServerListLoadResult result = Data::ServerList::LoadServerListFile(file); + Data::LogDataIssues("Server list:", result.issues); + if (Data::Items::HasErrors(result.issues)) { - wchar_t szMessage[256]; - ::mu_swprintf(szMessage, L"Data\\Local\\ServerList.bmd file not found.\r\n"); - g_ErrorReport.Write(szMessage); - ::MessageBox(g_hWnd, szMessage, NULL, MB_OK); - ::PostMessage(g_hWnd, WM_DESTROY, 0, 0); - return; + errorMessage = Data::DescribeDataErrors(ServerListName, file, result.issues); + return false; } + SetServerGroups(result.groups); + return true; +} -#pragma pack(push, 1) - typedef struct _SERVER_GROUP_INFO - { - WORD m_wIndex; - char m_szName[SLM_MAX_SERVER_NAME_LENGTH]; - BYTE m_byPos; - BYTE m_bySequence; - BYTE m_abyNonPVP[SLM_MAX_SERVER_COUNT]; - short m_nDescriptLen; - } SERVER_GROUP_INFO; -#pragma pack(pop) - - int nSize = sizeof(SERVER_GROUP_INFO); - SERVER_GROUP_INFO sServerGroupScript; - char szDescript[1024]; - SServerGroupInfo sServerGroupInfo; - int i; - - while (0 != ::fread(&sServerGroupScript, nSize, 1, fp)) +void CServerListManager::SetServerGroups(std::span groups) +{ + m_mapServerListScript.clear(); + int sequence = 0; + for (const Data::ServerList::ServerGroup& group : groups) { - BuxConvert((BYTE*)&sServerGroupScript, nSize); - ::fread(szDescript, sServerGroupScript.m_nDescriptLen, 1, fp); - BuxConvert((BYTE*)szDescript, sServerGroupScript.m_nDescriptLen); - - CMultiLanguage::ConvertFromUtf8(sServerGroupInfo.m_szName, sServerGroupScript.m_szName); - - sServerGroupInfo.m_byPos = sServerGroupScript.m_byPos; - sServerGroupInfo.m_bySequence = sServerGroupScript.m_bySequence; - for (i = 0; i < SLM_MAX_SERVER_COUNT; ++i) - sServerGroupInfo.m_abyNonPVP[i] = sServerGroupScript.m_abyNonPVP[i]; - - m_mapServerListScript.insert(std::make_pair(sServerGroupScript.m_wIndex, sServerGroupInfo)); + m_mapServerListScript[group.id] = {group, ++sequence}; } +} - ::fclose(fp); +#ifdef _EDITOR +void CServerListManager::RebuildServerGroups() +{ + const std::vector servers = m_receivedServers; + const int totalServer = m_iTotalServer; + Release(); + SetTotalServer(totalServer); + for (const ReceivedServer& server : servers) + { + InsertServerGroup(server.connectIndex, server.percent); + } } +#endif -const SServerGroupInfo* CServerListManager::GetServerGroupInfoInScript(WORD wServerGroupIndex) +const SServerGroupInfo* CServerListManager::GetServerGroupInfoInScript(int iServerGroupIndex) { - ServerListScriptMap::const_iterator iter = m_mapServerListScript.find(wServerGroupIndex); + ServerListScriptMap::const_iterator iter = m_mapServerListScript.find(iServerGroupIndex); if (iter == m_mapServerListScript.end()) return NULL; @@ -98,6 +104,10 @@ const SServerGroupInfo* CServerListManager::GetServerGroupInfoInScript(WORD wSer void CServerListManager::InsertServerGroup(int iConnectIndex, int iServerPercent) { +#ifdef _EDITOR + m_receivedServers.push_back({iConnectIndex, iServerPercent}); + ++m_receivedServersVersion; +#endif CServerGroup* pServerGroup = NULL; auto iterServerGroup = m_mapServerGroup.begin(); @@ -120,11 +130,12 @@ void CServerListManager::InsertServerGroup(int iConnectIndex, int iServerPercent } else { - pServerGroup = new CServerGroup; - - if (MakeServerGroup(iConnectIndex / MAX_SERVER_PER_GROUP, pServerGroup) == false) + // A server of a group the list does not have is not shown. + auto pNewServerGroup = std::make_unique(); + if (MakeServerGroup(iConnectIndex / MAX_SERVER_PER_GROUP, pNewServerGroup.get()) == false) return; + pServerGroup = pNewServerGroup.release(); m_mapServerGroup.insert(type_mapServerGroup::value_type(pServerGroup->m_iSequence, pServerGroup)); } @@ -139,21 +150,21 @@ bool CServerListManager::MakeServerGroup(IN int iServerGroupIndex, OUT CServerGr if (NULL == pServerGroupInfo) return false; - ::wcscpy(pServerGroup->m_szName, pServerGroupInfo->m_szName); - ::wcscpy(pServerGroup->m_szDescription, pServerGroupInfo->m_strDescript.c_str()); - pServerGroup->m_iSequence = (int)pServerGroupInfo->m_bySequence; - pServerGroup->m_iWidthPos = (int)pServerGroupInfo->m_byPos; + const Data::ServerList::ServerGroup& group = pServerGroupInfo->group; + const std::wstring name = Core::Text::FromUtf8(group.name); + const std::wstring description = Core::Text::FromUtf8(group.description); + wcsncpy_s(pServerGroup->m_szName, name.c_str(), _TRUNCATE); + wcsncpy_s(pServerGroup->m_szDescription, description.c_str(), _TRUNCATE); + pServerGroup->m_iSequence = pServerGroupInfo->sequence; + pServerGroup->m_iWidthPos = group.testServer ? CServerGroup::SBP_CENTER : CServerGroup::SBP_LEFT; pServerGroup->m_iServerIndex = iServerGroupIndex; pServerGroup->m_bPvPServer = true; - int i; - for (i = 0; i < SLM_MAX_SERVER_COUNT; ++i) + for (int i = 0; i < MAX_SERVER_PER_GROUP; ++i) { - pServerGroup->m_abyNonPvpServer[i] = pServerGroupInfo->m_abyNonPVP[i]; - if (0x01 & pServerGroup->m_abyNonPvpServer[i]) + pServerGroup->m_abyNonPvpServer[i] = static_cast(group.channels[i]); + if (NonPvpBit & pServerGroup->m_abyNonPvpServer[i]) pServerGroup->m_bPvPServer = false; } - for (; i < MAX_SERVER_PER_GROUP; ++i) - pServerGroup->m_abyNonPvpServer[i] = 0; return true; } @@ -181,28 +192,12 @@ void CServerListManager::InsertServer(CServerGroup* pServerGroup, int iConnectIn iTextIndex = 562; } - switch (pServerInfo->m_byNonPvP) - { - case 0: - mu_swprintf(pServerInfo->m_bName, L"%ls-%d %ls", pServerGroup->m_szName, - pServerInfo->m_iIndex, I18N::Game::Lookup(iTextIndex)); - break; - - case 1: - mu_swprintf(pServerInfo->m_bName, L"%ls-%d(Non-PVP) %ls", pServerGroup->m_szName, - pServerInfo->m_iIndex, I18N::Game::Lookup(iTextIndex)); - break; - - case 2: - mu_swprintf(pServerInfo->m_bName, L"%ls-%d(Gold PVP) %ls", pServerGroup->m_szName, - pServerInfo->m_iIndex, I18N::Game::Lookup(iTextIndex)); - break; - - case 3: - mu_swprintf(pServerInfo->m_bName, L"%ls-%d(Gold) %ls", pServerGroup->m_szName, - pServerInfo->m_iIndex, I18N::Game::Lookup(iTextIndex)); - break; - } + // The suffix is ASCII, so each char is its wchar_t. + const std::string_view suffix = + Data::ServerList::GetChannelSuffix(static_cast(pServerInfo->m_byNonPvP)); + const std::wstring wideSuffix(suffix.begin(), suffix.end()); + mu_swprintf(pServerInfo->m_bName, L"%ls-%d%ls %ls", pServerGroup->m_szName, pServerInfo->m_iIndex, + wideSuffix.c_str(), I18N::Game::Lookup(iTextIndex)); pServerGroup->InsertServerInfo(pServerInfo); } @@ -265,7 +260,6 @@ int CServerListManager::GetSelectServerIndex() return m_iSelectServerIndex; } - BYTE CServerListManager::GetNonPVPInfo() { return m_byNonPvP; @@ -273,7 +267,7 @@ BYTE CServerListManager::GetNonPVPInfo() bool CServerListManager::IsNonPvP() { - return bool(0x01 & GetNonPVPInfo()); + return bool(NonPvpBit & GetNonPVPInfo()); } void CServerListManager::SetTotalServer(int iTotalServer) diff --git a/src/source/Network/Server/ServerListManager.h b/src/source/Network/Server/ServerListManager.h index 7355791a58..01cc137327 100644 --- a/src/source/Network/Server/ServerListManager.h +++ b/src/source/Network/Server/ServerListManager.h @@ -4,19 +4,20 @@ #pragma once #include "ServerGroup.h" -#define SLM_MAX_SERVER_NAME_LENGTH 32 -#define SLM_MAX_SERVER_COUNT 15 +#include "Data/GameData/ServerListData/ServerListGroup.h" +#include +#include +#include + +// A group of Data/Local/ServerList.json, with its place in the list. struct SServerGroupInfo { - wchar_t m_szName[SLM_MAX_SERVER_NAME_LENGTH + 1]; - BYTE m_byPos; - BYTE m_bySequence; - BYTE m_abyNonPVP[SLM_MAX_SERVER_COUNT]; - std::wstring m_strDescript; + Data::ServerList::ServerGroup group; + int sequence = 0; }; -typedef std::map ServerListScriptMap; +typedef std::map ServerListScriptMap; typedef std::map type_mapServerGroup; class CServerListManager @@ -32,7 +33,11 @@ class CServerListManager void InsertServerGroup(int iConnectIndex, int iServerPercent); void Release(); - void LoadServerListScript(); + // Reads Data/Local/ServerList.json. False when it has errors; they are in + // errorMessage then, for the player. + bool LoadServerListScript(std::string& errorMessage); + // The groups the login screen names the servers with, in their order. + void SetServerGroups(std::span groups); void SetFirst(); bool GetNext(OUT CServerGroup*& pServerGroup); CServerGroup* GetServerGroupByBtnPos(int iBtnPos); @@ -47,8 +52,31 @@ class CServerListManager void SetTotalServer(int iTotalServer); int GetTotalServer(); +#ifdef _EDITOR + // A server of the last list the connect server sent. + struct ReceivedServer + { + int connectIndex = 0; + int percent = 0; + }; + + const std::vector& GetReceivedServers() const + { + return m_receivedServers; + } + // Changes each time the received servers change, so a view of them is + // built again only then. + int GetReceivedServersVersion() const + { + return m_receivedServersVersion; + } + // Builds the groups of the last list the connect server sent again, with + // the groups SetServerGroups set since. + void RebuildServerGroups(); +#endif + protected: - const SServerGroupInfo* GetServerGroupInfoInScript(WORD wServerGroupIndex); + const SServerGroupInfo* GetServerGroupInfoInScript(int iServerGroupIndex); bool MakeServerGroup(IN int iServerGroupIndex, OUT CServerGroup* pServerGroup); void InsertServer(CServerGroup* pServerGroup, int iConnectIndex, int iServerPercent); @@ -63,6 +91,10 @@ class CServerListManager protected: ServerListScriptMap m_mapServerListScript; +#ifdef _EDITOR + std::vector m_receivedServers; + int m_receivedServersVersion = 0; +#endif }; #define g_ServerListManager CServerListManager::GetInstance() diff --git a/src/source/UI/Windows/ServerSelWin.cpp b/src/source/UI/Windows/ServerSelWin.cpp index 026c9dd0d2..187526a96c 100644 --- a/src/source/UI/Windows/ServerSelWin.cpp +++ b/src/source/UI/Windows/ServerSelWin.cpp @@ -337,8 +337,42 @@ bool CServerSelWin::Render() return true; } +#ifdef _EDITOR +int CServerSelWin::GetOpenGroupId() const +{ + if (m_iSelectServerBtnIndex == -1) + return -1; + + // By its button, not m_pSelectServerGroup: that can be left on a deleted group. + const CServerGroup* pServerGroup = g_ServerListManager->GetServerGroupByBtnPos(m_iSelectServerBtnIndex); + return pServerGroup != nullptr ? pServerGroup->m_iServerIndex : -1; +} + +void CServerSelWin::ShowRebuiltGroups(int openGroupId) +{ + // The first pass gives the rebuilt groups their buttons. + m_iSelectServerBtnIndex = -1; + UpdateDisplay(); + if (openGroupId < 0) + return; + + CServerGroup* pServerGroup = nullptr; + g_ServerListManager->SetFirst(); + while (g_ServerListManager->GetNext(pServerGroup)) + { + if (pServerGroup->m_iServerIndex == openGroupId && pServerGroup->m_iBtnPos >= 0) + { + m_iSelectServerBtnIndex = pServerGroup->m_iBtnPos; + UpdateDisplay(); + return; + } + } +} +#endif + // Called only at the point of an actual state change, not polled from Render() every frame -- -// this window's state only ever changes from its own click handlers. +// this window's state only changes from its own click handlers and, in editor builds, from +// ShowRebuiltGroups after the Server List window built the groups again. void CServerSelWin::SyncRmlModel() { if (!m_RmlView.Document()) return; diff --git a/src/source/UI/Windows/ServerSelWin.h b/src/source/UI/Windows/ServerSelWin.h index c5977da19d..a97b761d23 100644 --- a/src/source/UI/Windows/ServerSelWin.h +++ b/src/source/UI/Windows/ServerSelWin.h @@ -40,6 +40,15 @@ class CServerSelWin : public mu::ui::window::CObject // such group or server, or when the server is full. bool SelectServer(const wchar_t* groupName, int serverIndex); +#ifdef _EDITOR + // The ID (CServerGroup::m_iServerIndex) of the open group, or -1; for the + // editor, before it builds the groups again. + int GetOpenGroupId() const; + // After the editor built the groups again: shows them, with the group of + // that ID open again wherever it is in the list now, or none when it is gone. + void ShowRebuiltGroups(int openGroupId); +#endif + // mu::ui::window::IObject bool Render() override; bool Update() override; diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 33c8a9a9f4..ca97d4c797 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -34,6 +34,20 @@ target_compile_definitions(light_mood_data_tests PRIVATE MU_TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/src/bin/Data" ) +# The server groups of the login screen (Data/Local/ServerList.json). +mu_add_test( + NAME server_list_data_tests + SOURCES + test_server_list_edits.cpp + test_server_list_json.cpp + test_server_list_manager.cpp + LINK_LIBS + MuClient +) +target_compile_definitions(server_list_data_tests PRIVATE + MU_TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/src/bin/Data" +) + # The look profile of the Enhanced rendering features (Data/Render/Look.json) and the player's own # tuning of it ([EnhancedTuning] of config.ini). mu_add_test( diff --git a/tests/data/test_server_list_edits.cpp b/tests/data/test_server_list_edits.cpp new file mode 100644 index 0000000000..f2f1d7af7f --- /dev/null +++ b/tests/data/test_server_list_edits.cpp @@ -0,0 +1,259 @@ +#include "stdafx.h" + +#include "doctest.h" + +#ifdef _EDITOR + +#include "I18N/All.h" +#include "UI/ServerList/ServerListEdits.h" + +#include +#include + +using Data::ServerList::ChannelType; +using MuEditor::ServerList::ServerGroup; +using MuEditor::ServerList::ServerListEdits; + +namespace +{ +ServerGroup Group(int id, const std::string& name) +{ + ServerGroup group; + group.id = id; + group.name = name; + return group; +} + +ServerListEdits ThreeGroups() +{ + ServerListEdits edits; + edits.Reset({Group(0, "Valhalla"), Group(1, "Helheim"), Group(2, "Midgard")}); + return edits; +} + +std::vector Names(const ServerListEdits& edits) +{ + std::vector names; + for (const ServerGroup& group : edits.GetGroups()) + { + names.push_back(group.name); + } + return names; +} +} // namespace + +TEST_CASE("ServerListEdits: a reset has nothing to undo and nothing unsaved") +{ + const ServerListEdits edits = ThreeGroups(); + + CHECK_FALSE(edits.CanUndo()); + CHECK_FALSE(edits.CanRedo()); + CHECK_FALSE(edits.HasUnsavedChanges()); +} + +TEST_CASE("ServerListEdits: what changed since the last step is one undo step") +{ + ServerListEdits edits = ThreeGroups(); + edits.EditGroups()[0].name = "V"; + edits.EditGroups()[0].name = "Val"; + edits.EditGroups()[0].channels[0] = ChannelType::Gold; + edits.Commit(); + + CHECK(edits.CanUndo()); + CHECK(edits.HasUnsavedChanges()); + + edits.Undo(); + CHECK(edits.GetGroups()[0].name == "Valhalla"); + CHECK(edits.GetGroups()[0].channels[0] == ChannelType::Normal); + CHECK_FALSE(edits.CanUndo()); + CHECK_FALSE(edits.HasUnsavedChanges()); + + edits.Redo(); + CHECK(edits.GetGroups()[0].name == "Val"); + CHECK(edits.GetGroups()[0].channels[0] == ChannelType::Gold); +} + +TEST_CASE("ServerListEdits: undo also takes back a change not yet committed") +{ + ServerListEdits edits = ThreeGroups(); + edits.EditGroups()[1].name = "Hel"; + + edits.Undo(); + + CHECK(edits.GetGroups()[1].name == "Helheim"); + CHECK(edits.CanRedo()); +} + +TEST_CASE("ServerListEdits: a new change drops what could be redone") +{ + ServerListEdits edits = ThreeGroups(); + edits.RemoveGroup(2); + edits.Commit(); + edits.Undo(); + REQUIRE(edits.CanRedo()); + + edits.MoveGroup(0, 1); + edits.Commit(); + + CHECK_FALSE(edits.CanRedo()); +} + +TEST_CASE("ServerListEdits: committing without a change adds no step") +{ + ServerListEdits edits = ThreeGroups(); + edits.Commit(); + CHECK_FALSE(edits.CanUndo()); + + edits.EditGroups()[0].name = "X"; + edits.EditGroups()[0].name = "Valhalla"; + edits.Commit(); + CHECK_FALSE(edits.CanUndo()); +} + +TEST_CASE("ServerListEdits: the undo list keeps the last steps") +{ + ServerListEdits edits = ThreeGroups(); + for (size_t step = 0; step < ServerListEdits::MaxUndoSteps + 10; ++step) + { + edits.EditGroups()[0].name = "Step " + std::to_string(step); + edits.Commit(); + } + size_t undone = 0; + while (edits.CanUndo()) + { + edits.Undo(); + ++undone; + } + CHECK(undone == ServerListEdits::MaxUndoSteps); + CHECK(edits.GetGroups()[0].name == "Step 9"); +} + +TEST_CASE("ServerListEdits: saved groups are not unsaved changes") +{ + ServerListEdits edits = ThreeGroups(); + edits.EditGroups()[2].testServer = true; + edits.Commit(); + edits.MarkSaved(); + + CHECK_FALSE(edits.HasUnsavedChanges()); + // Undo stays across a save; the file then differs again. + edits.Undo(); + CHECK(edits.HasUnsavedChanges()); +} + +TEST_CASE("ServerListEdits: groups from a read with errors are not complete until a clean read") +{ + ServerListEdits edits; + edits.Reset({Group(0, "Valhalla")}, false); + CHECK_FALSE(edits.IsComplete()); + + edits.EditGroups()[0].name = "Asgard"; + edits.Commit(); + edits.MarkSaved(); + CHECK_FALSE(edits.IsComplete()); + + edits.Reset({Group(0, "Valhalla"), Group(1, "Helheim")}); + CHECK(edits.IsComplete()); +} + +TEST_CASE("ServerListEdits: a new group gets the smallest free ID at the end") +{ + ServerListEdits edits; + edits.Reset({Group(0, "A"), Group(2, "B")}); + + const std::optional added = edits.AddGroup("New"); + + REQUIRE(added == size_t{2}); + CHECK(edits.GetGroups()[2].id == 1); + CHECK(edits.GetGroups()[2].name == "New"); +} + +TEST_CASE("ServerListEdits: a new group gets the wanted ID when it is free") +{ + ServerListEdits edits = ThreeGroups(); + + REQUIRE(edits.AddGroup("Sent", 7).has_value()); + CHECK(edits.GetGroups().back().id == 7); + + REQUIRE(edits.AddGroup("Taken", 1).has_value()); + CHECK(edits.GetGroups().back().id == 3); +} + +TEST_CASE("ServerListEdits: remove and move") +{ + ServerListEdits edits = ThreeGroups(); + + CHECK(edits.MoveGroup(2, -1)); + CHECK(Names(edits) == std::vector{"Valhalla", "Midgard", "Helheim"}); + CHECK_FALSE(edits.MoveGroup(0, -1)); + CHECK_FALSE(edits.MoveGroup(2, 1)); + CHECK_FALSE(edits.MoveGroup(5, 1)); + + edits.RemoveGroup(0); + CHECK(Names(edits) == std::vector{"Midgard", "Helheim"}); + edits.RemoveGroup(9); + CHECK(edits.GetGroups().size() == 2); +} + +TEST_CASE("ServerListEdits: each step is named for the editor bar") +{ + I18N::SetLocale("en"); + ServerListEdits edits = ThreeGroups(); + CHECK(edits.GetUndoLabel().empty()); + + edits.EditGroups()[0].channels[2] = ChannelType::Gold; + // Not committed yet (a field being typed in): named all the same. + CHECK(edits.GetUndoLabel() == "Valhalla: channel 3"); + edits.Commit(); + CHECK(edits.GetUndoLabel() == "Valhalla: channel 3"); + + edits.EditGroups()[1].name = "Asgard"; + edits.Commit(); + CHECK(edits.GetUndoLabel() == "Helheim: name"); + + edits.MoveGroup(2, -1); + edits.Commit(); + CHECK(edits.GetUndoLabel() == "Move Midgard"); + + edits.RemoveGroup(0); + edits.Commit(); + CHECK(edits.GetUndoLabel() == "Remove Valhalla"); + + edits.AddGroup("New Group"); + edits.Commit(); + CHECK(edits.GetUndoLabel() == "Add New Group"); + + // A step keeps its name when it is undone and redone. + edits.Undo(); + CHECK(edits.GetRedoLabel() == "Add New Group"); + CHECK(edits.GetUndoLabel() == "Remove Valhalla"); + edits.Redo(); + CHECK(edits.GetUndoLabel() == "Add New Group"); + CHECK(edits.GetRedoLabel().empty()); +} + +TEST_CASE("ServerListEdits: field changes are named by the group as it was") +{ + I18N::SetLocale("en"); + ServerListEdits edits = ThreeGroups(); + + edits.EditGroups()[2].id = 7; + CHECK(edits.GetUndoLabel() == "Midgard: ID"); + edits.Commit(); + edits.EditGroups()[2].description = "New"; + CHECK(edits.GetUndoLabel() == "Midgard: description"); + edits.Commit(); + edits.EditGroups()[2].testServer = true; + CHECK(edits.GetUndoLabel() == "Midgard: test server"); +} + +TEST_CASE("ServerListEdits: IDs in use") +{ + const ServerListEdits edits = ThreeGroups(); + + CHECK(edits.IsIdUsed(1)); + CHECK_FALSE(edits.IsIdUsed(3)); + CHECK(edits.FindFreeId() == 3); +} + +#endif // _EDITOR diff --git a/tests/data/test_server_list_json.cpp b/tests/data/test_server_list_json.cpp new file mode 100644 index 0000000000..c9a94bddba --- /dev/null +++ b/tests/data/test_server_list_json.cpp @@ -0,0 +1,321 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "Data/GameData/ItemData/ItemJsonCommon.h" +#include "Data/GameData/ServerListData/ServerListJson.h" + +#include +#include +#include +#include +#include + +using Data::Items::HasErrors; +using Data::Items::ItemDataIssue; +using Data::Items::ItemDataIssueSeverity; +using Data::ServerList::ChannelType; +using Data::ServerList::ServerGroup; + +namespace +{ +struct ReadResult +{ + std::vector groups; + std::vector issues; +}; + +ReadResult Read(const std::string& text) +{ + ReadResult result; + Data::ServerList::ReadServerListJson(text, "ServerList.json", result.groups, result.issues); + return result; +} + +std::string Messages(const std::vector& issues) +{ + std::string text; + for (const ItemDataIssue& issue : issues) + { + text += issue.ToString() + "\n"; + } + return text; +} + +bool HasIssueAbout(const std::vector& issues, const std::string& field, const std::string& message) +{ + return std::any_of(issues.begin(), issues.end(), [&](const ItemDataIssue& issue) + { return issue.field == field && issue.message.find(message) != std::string::npos; }); +} + +bool HasWarnings(const std::vector& issues) +{ + return std::any_of(issues.begin(), issues.end(), + [](const ItemDataIssue& issue) { return issue.severity == ItemDataIssueSeverity::Warning; }); +} + +// One group with nothing else around it. +std::string GroupFile(const std::string& group) +{ + return R"({ "formatVersion": 1, "groups": [ )" + group + " ] }"; +} + +const std::string ValidFile = R"({ + "formatVersion": 1, + "groups": [ + { "id": 0, "name": "Valhalla", "channels": {"1": "gold", "5": "goldPvp", "6": "nonPvp", "20": "normal"} }, + { "id": 3, "name": "Kanturu", "description": "No PvP here", "testServer": true } + ] +})"; +} // namespace + +TEST_CASE("ServerListJson: reads the groups in their order") +{ + const ReadResult read = Read(ValidFile); + + CHECK_MESSAGE(read.issues.empty(), Messages(read.issues)); + REQUIRE(read.groups.size() == 2); + + const ServerGroup& valhalla = read.groups[0]; + CHECK(valhalla.id == 0); + CHECK(valhalla.name == "Valhalla"); + CHECK(valhalla.description.empty()); + CHECK_FALSE(valhalla.testServer); + CHECK(valhalla.channels[0] == ChannelType::Gold); + CHECK(valhalla.channels[1] == ChannelType::Normal); + CHECK(valhalla.channels[4] == ChannelType::GoldPvp); + CHECK(valhalla.channels[5] == ChannelType::NonPvp); + CHECK(valhalla.channels[19] == ChannelType::Normal); + + const ServerGroup& kanturu = read.groups[1]; + CHECK(kanturu.id == 3); + CHECK(kanturu.description == "No PvP here"); + CHECK(kanturu.testServer); + CHECK(std::all_of(kanturu.channels.begin(), kanturu.channels.end(), + [](ChannelType type) { return type == ChannelType::Normal; })); +} + +TEST_CASE("ServerListJson: writes what it reads") +{ + const ReadResult read = Read(ValidFile); + const std::string written = Data::ServerList::WriteServerListJson(read.groups); + const ReadResult readBack = Read(written); + + CHECK_MESSAGE(readBack.issues.empty(), Messages(readBack.issues)); + CHECK(readBack.groups == read.groups); + // Normal channels and empty or false fields are left out. + CHECK(written.find("\"20\"") == std::string::npos); + CHECK(written.find("\"description\": \"\"") == std::string::npos); + CHECK(written.find("\"testServer\": false") == std::string::npos); + // The channels of a group are on one line. + CHECK(written.find(R"("channels": {"1": "gold", "5": "goldPvp", "6": "nonPvp"})") != std::string::npos); +} + +TEST_CASE("ServerListJson: an empty list is a valid file") +{ + const ReadResult read = Read(R"({ "formatVersion": 1, "groups": [] })"); + + CHECK(read.issues.empty()); + CHECK(read.groups.empty()); + CHECK(Data::ServerList::WriteServerListJson(read.groups) == "{\n \"formatVersion\": 1,\n \"groups\": []\n}\n"); +} + +TEST_CASE("ServerListJson: the file needs its version and a list of groups") +{ + CHECK(HasErrors(Read("not json").issues)); + CHECK(HasErrors(Read(R"({ "groups": [] })").issues)); + CHECK(HasErrors(Read(R"({ "formatVersion": 2, "groups": [] })").issues)); + + const ReadResult noList = Read(R"({ "formatVersion": 1, "groups": {} })"); + CHECK(HasIssueAbout(noList.issues, "groups", "not a list")); +} + +TEST_CASE("ServerListJson: a group needs an id and a name") +{ + const ReadResult noId = Read(GroupFile(R"({ "name": "Valhalla" })")); + CHECK(HasIssueAbout(noId.issues, "groups[0].id", "missing")); + CHECK(noId.groups.empty()); + + const ReadResult badId = Read(GroupFile(R"({ "id": 3277, "name": "Valhalla" })")); + CHECK(HasIssueAbout(badId.issues, "groups[0].id", "from 0 to 3276")); + + const ReadResult negativeId = Read(GroupFile(R"({ "id": -1, "name": "Valhalla" })")); + CHECK(HasErrors(negativeId.issues)); + + const ReadResult noName = Read(GroupFile(R"({ "id": 0 })")); + CHECK(HasIssueAbout(noName.issues, "groups[0].name", "missing or empty")); + + const ReadResult emptyName = Read(GroupFile(R"({ "id": 0, "name": "" })")); + CHECK(HasIssueAbout(emptyName.issues, "groups[0].name", "missing or empty")); + + const ReadResult notAnObject = Read(GroupFile("5")); + CHECK(HasIssueAbout(notAnObject.issues, "groups[0]", "must be an object")); +} + +TEST_CASE("ServerListJson: a group id is in the list once") +{ + const ReadResult read = Read(R"({ "formatVersion": 1, "groups": [ + { "id": 1, "name": "Helheim" }, { "id": 1, "name": "Midgard" } ] })"); + + CHECK(HasIssueAbout(read.issues, "groups[1].id", "more than once")); + REQUIRE(read.groups.size() == 1); + CHECK(read.groups[0].name == "Helheim"); +} + +TEST_CASE("ServerListJson: channels are numbered 1 to 20 and have a known type") +{ + const ReadResult zero = Read(GroupFile(R"({ "id": 0, "name": "A", "channels": {"0": "gold"} })")); + CHECK(HasIssueAbout(zero.issues, "groups[0].channels.0", "1 to 20")); + + const ReadResult tooHigh = Read(GroupFile(R"({ "id": 0, "name": "A", "channels": {"21": "gold"} })")); + CHECK(HasIssueAbout(tooHigh.issues, "groups[0].channels.21", "1 to 20")); + + const ReadResult notANumber = Read(GroupFile(R"({ "id": 0, "name": "A", "channels": {"1a": "gold"} })")); + CHECK(HasErrors(notANumber.issues)); + + const ReadResult unknownType = Read(GroupFile(R"({ "id": 0, "name": "A", "channels": {"1": "silver"} })")); + CHECK(HasIssueAbout(unknownType.issues, "groups[0].channels.1", "normal, nonPvp, goldPvp or gold")); + + const ReadResult notAnObject = Read(GroupFile(R"({ "id": 0, "name": "A", "channels": ["gold"] })")); + CHECK(HasIssueAbout(notAnObject.issues, "groups[0].channels", "object")); +} + +TEST_CASE("ServerListJson: a name or description that is too long is cut") +{ + const std::string longName(40, 'x'); + const std::string longDescription(300, 'y'); + const ReadResult read = + Read(GroupFile(R"({ "id": 0, "name": ")" + longName + R"(", "description": ")" + longDescription + R"(" })")); + + CHECK_FALSE(HasErrors(read.issues)); + CHECK(HasIssueAbout(read.issues, "groups[0].name", "longer than 32 characters")); + CHECK(HasIssueAbout(read.issues, "groups[0].description", "longer than 254 characters")); + REQUIRE(read.groups.size() == 1); + CHECK(read.groups[0].name == std::string(32, 'x')); + CHECK(read.groups[0].description.size() == 254); +} + +TEST_CASE("ServerListJson: names are counted in UTF-16 units, not bytes") +{ + // 32 two-byte characters fit; the cut never splits one. + std::string name; + for (int i = 0; i < 33; ++i) + { + name += "\xC3\xA4"; // a-umlaut + } + const ReadResult read = Read(GroupFile(R"({ "id": 0, "name": ")" + name + R"(" })")); + + REQUIRE(read.groups.size() == 1); + CHECK(read.groups[0].name == name.substr(0, 64)); +} + +TEST_CASE("ServerListJson: an emoji counts as two UTF-16 units") +{ + const std::string emoji = "\xF0\x9F\x98\x80"; // U+1F600, a surrogate pair in UTF-16 + std::string emojis; + for (int i = 0; i < 17; ++i) + { + emojis += emoji; + } + const ReadResult onlyEmoji = Read(GroupFile(R"({ "id": 0, "name": ")" + emojis + R"(" })")); + REQUIRE(onlyEmoji.groups.size() == 1); + CHECK(onlyEmoji.groups[0].name == emojis.substr(0, 16 * emoji.size())); + + // 31 letters and an emoji are 33 units: the emoji goes, whole. + const std::string mixed = std::string(31, 'x') + emoji; + const ReadResult cut = Read(GroupFile(R"({ "id": 0, "name": ")" + mixed + R"(" })")); + REQUIRE(cut.groups.size() == 1); + CHECK(cut.groups[0].name == std::string(31, 'x')); + CHECK(HasIssueAbout(cut.issues, "groups[0].name", "an emoji counts as two")); + + // 30 letters and an emoji are exactly 32. + std::string fits = std::string(30, 'x') + emoji; + CHECK_FALSE(Data::ServerList::CutToUtf16Length(fits, Data::ServerList::MaxServerGroupNameLength)); + CHECK(fits == std::string(30, 'x') + emoji); +} + +TEST_CASE("ServerListJson: unknown fields and a second test server are warnings") +{ + const ReadResult read = Read(R"({ "formatVersion": 1, "color": "red", "groups": [ + { "id": 0, "name": "A", "testServer": true, "order": 3 }, + { "id": 1, "name": "B", "testServer": true } ] })"); + + CHECK_FALSE(HasErrors(read.issues)); + CHECK(HasWarnings(read.issues)); + CHECK(HasIssueAbout(read.issues, "color", "unknown field")); + CHECK(HasIssueAbout(read.issues, "groups[0].order", "unknown field")); + CHECK(HasIssueAbout(read.issues, "testServer", "only the first test server group")); + CHECK(read.groups.size() == 2); +} + +TEST_CASE("ServerListJson: wrong types are errors") +{ + CHECK(HasIssueAbout(Read(GroupFile(R"({ "id": 0, "name": 5 })")).issues, "groups[0].name", "must be a text")); + CHECK(HasIssueAbout(Read(GroupFile(R"({ "id": 0, "name": "A", "testServer": 1 })")).issues, "groups[0].testServer", + "true or false")); + CHECK(HasIssueAbout(Read(GroupFile(R"({ "id": "0", "name": "A" })")).issues, "groups[0].id", "whole number")); +} + +TEST_CASE("ServerListJson: channel type names") +{ + for (const ChannelType type : {ChannelType::Normal, ChannelType::NonPvp, ChannelType::GoldPvp, ChannelType::Gold}) + { + CHECK(Data::ServerList::FindChannelType(Data::ServerList::GetChannelTypeName(type)) == type); + } + CHECK_FALSE(Data::ServerList::FindChannelType("Gold").has_value()); +} + +TEST_CASE("ServerListJson: the shipped file reads without problems and is written as the editor writes it") +{ + // MU_TEST_DATA_DIR is the client folder's Data; the file name starts with it. + const std::filesystem::path file = + std::filesystem::path(MU_TEST_DATA_DIR).parent_path() / Data::ServerList::ServerListFile; + const Data::ServerList::ServerListLoadResult loaded = Data::ServerList::LoadServerListFile(file); + + CHECK_MESSAGE(loaded.issues.empty(), Messages(loaded.issues)); + REQUIRE(loaded.groups.size() == 3); + CHECK(loaded.groups[0].name == "Valhalla"); + CHECK(loaded.groups[1].name == "Helheim"); + CHECK(loaded.groups[2].name == "Midgard"); + + const std::optional text = Data::Items::Json::ReadTextFile(file); + REQUIRE(text.has_value()); + CHECK(Data::ServerList::WriteServerListJson(loaded.groups) == *text); +} + +TEST_CASE("ServerListJson: a missing file is an error") +{ + const Data::ServerList::ServerListLoadResult loaded = + Data::ServerList::LoadServerListFile("does/not/exist/ServerList.json"); + + CHECK(HasIssueAbout(loaded.issues, "", "could not be read")); + CHECK(loaded.groups.empty()); +} + +TEST_CASE("ServerListJson: save writes the file and refuses a list with errors") +{ + const std::filesystem::path folder = std::filesystem::temp_directory_path() / "mu_server_list_save_test"; + std::filesystem::remove_all(folder); + const std::filesystem::path file = folder / "Local" / "ServerList.json"; + const ReadResult read = Read(ValidFile); + + std::vector issues; + REQUIRE(Data::ServerList::SaveServerListFile(file, read.groups, issues)); + CHECK(issues.empty()); + const Data::ServerList::ServerListLoadResult saved = Data::ServerList::LoadServerListFile(file); + CHECK(saved.issues.empty()); + CHECK(saved.groups == read.groups); + + std::vector broken = read.groups; + broken[1].name.clear(); + broken.push_back(read.groups[0]); + std::vector refused; + CHECK_FALSE(Data::ServerList::SaveServerListFile(file, broken, refused)); + CHECK(HasIssueAbout(refused, "groups[1].name", "missing or empty")); + CHECK(HasIssueAbout(refused, "groups[2].id", "more than once")); + // The file stays as it was. + CHECK(Data::ServerList::LoadServerListFile(file).groups == read.groups); + CHECK_FALSE(std::filesystem::exists(file.string() + ".tmp")); + + std::filesystem::remove_all(folder); +} diff --git a/tests/data/test_server_list_manager.cpp b/tests/data/test_server_list_manager.cpp new file mode 100644 index 0000000000..e87237f2d8 --- /dev/null +++ b/tests/data/test_server_list_manager.cpp @@ -0,0 +1,209 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "I18N/All.h" +#include "Network/Server/ServerListManager.h" + +#include +#include + +using Data::ServerList::ChannelType; +using Data::ServerList::ServerGroup; + +namespace +{ +// The shared instance's constructor is protected; each test gets a manager of its own. +class TestServerListManager : public CServerListManager +{ +public: + TestServerListManager() = default; +}; + +// The load the connect server sends; the login screen's text for it (InsertServer). +constexpr int LowLoad = 10; +constexpr int LowLoadText = 562; + +ServerGroup Group(int id, const std::string& name) +{ + ServerGroup group; + group.id = id; + group.name = name; + return group; +} + +int ServerId(int groupId, int channel) +{ + return groupId * MAX_SERVER_PER_GROUP + channel - 1; +} + +std::vector GroupsInOrder(CServerListManager& manager) +{ + std::vector groups; + CServerGroup* group = nullptr; + manager.SetFirst(); + while (manager.GetNext(group)) + { + groups.push_back(group); + } + return groups; +} + +std::wstring ChannelName(const wchar_t* groupAndChannel) +{ + return std::wstring(groupAndChannel) + L" " + I18N::Game::Lookup(LowLoadText); +} +} // namespace + +TEST_CASE("ServerListManager: the list order is the order of the groups") +{ + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(2, "Midgard"), Group(0, "Valhalla"), Group(1, "Helheim")}); + for (const int groupId : {0, 1, 2}) + { + manager.InsertServerGroup(ServerId(groupId, 1), LowLoad); + } + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 3); + CHECK(std::wstring(groups[0]->m_szName) == L"Midgard"); + CHECK(groups[0]->m_iSequence == 1); + CHECK(groups[0]->m_iServerIndex == 2); + CHECK(std::wstring(groups[1]->m_szName) == L"Valhalla"); + CHECK(groups[1]->m_iSequence == 2); + CHECK(std::wstring(groups[2]->m_szName) == L"Helheim"); + CHECK(groups[2]->m_iSequence == 3); +} + +TEST_CASE("ServerListManager: a server of a group the list does not have is not shown") +{ + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(0, "Valhalla")}); + + manager.InsertServerGroup(ServerId(3, 1), LowLoad); + CHECK(manager.GetServerGroupSize() == 0); + + manager.InsertServerGroup(ServerId(0, 2), LowLoad); + CHECK(manager.GetServerGroupSize() == 1); +} + +TEST_CASE("ServerListManager: the servers of a group are its channels") +{ + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(1, "Helheim")}); + manager.InsertServerGroup(ServerId(1, 1), LowLoad); + manager.InsertServerGroup(ServerId(1, 20), LowLoad); + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 1); + REQUIRE(groups[0]->GetServerSize() == 2); + CHECK(groups[0]->GetServerInfo(0)->m_iIndex == 1); + CHECK(groups[0]->GetServerInfo(0)->m_iConnectIndex == ServerId(1, 1)); + CHECK(groups[0]->GetServerInfo(1)->m_iIndex == 20); + CHECK(groups[0]->GetServerInfo(1)->m_iConnectIndex == ServerId(1, 20)); +} + +TEST_CASE("ServerListManager: the channel types reach the channels and their names") +{ + ServerGroup valhalla = Group(0, "Valhalla"); + valhalla.channels[0] = ChannelType::Gold; + valhalla.channels[1] = ChannelType::GoldPvp; + valhalla.channels[2] = ChannelType::NonPvp; + valhalla.channels[19] = ChannelType::Gold; + TestServerListManager manager; + manager.SetServerGroups(std::vector{valhalla}); + for (const int channel : {1, 2, 3, 4, 20}) + { + manager.InsertServerGroup(ServerId(0, channel), LowLoad); + } + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 1); + CServerGroup* group = groups[0]; + for (int channel = 0; channel < MAX_SERVER_PER_GROUP; ++channel) + { + CAPTURE(channel); + CHECK(group->m_abyNonPvpServer[channel] == static_cast(valhalla.channels[channel])); + } + // A group with a non-PvP channel shows no PvP notice. + CHECK_FALSE(group->m_bPvPServer); + + REQUIRE(group->GetServerSize() == 5); + CHECK(group->GetServerInfo(0)->m_byNonPvP == static_cast(ChannelType::Gold)); + CHECK(std::wstring(group->GetServerInfo(0)->m_bName) == ChannelName(L"Valhalla-1(Gold)")); + CHECK(std::wstring(group->GetServerInfo(1)->m_bName) == ChannelName(L"Valhalla-2(Gold PVP)")); + CHECK(std::wstring(group->GetServerInfo(2)->m_bName) == ChannelName(L"Valhalla-3(Non-PVP)")); + CHECK(std::wstring(group->GetServerInfo(3)->m_bName) == ChannelName(L"Valhalla-4")); + CHECK(std::wstring(group->GetServerInfo(4)->m_bName) == ChannelName(L"Valhalla-20(Gold)")); +} + +TEST_CASE("ServerListManager: a group without a non-PvP channel shows the PvP notice") +{ + ServerGroup helheim = Group(1, "Helheim"); + helheim.channels[0] = ChannelType::GoldPvp; + TestServerListManager manager; + manager.SetServerGroups(std::vector{helheim}); + manager.InsertServerGroup(ServerId(1, 1), LowLoad); + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 1); + CHECK(groups[0]->m_bPvPServer); +} + +TEST_CASE("ServerListManager: description and test server reach the group") +{ + ServerGroup test = Group(9, "Test"); + test.description = "Only for the team"; + test.testServer = true; + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(0, "Valhalla"), test}); + manager.InsertServerGroup(ServerId(0, 1), LowLoad); + manager.InsertServerGroup(ServerId(9, 1), LowLoad); + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 2); + CHECK(groups[0]->m_iWidthPos == CServerGroup::SBP_LEFT); + CHECK(std::wstring(groups[0]->m_szDescription).empty()); + CHECK(groups[1]->m_iWidthPos == CServerGroup::SBP_CENTER); + CHECK(std::wstring(groups[1]->m_szDescription) == L"Only for the team"); +} + +TEST_CASE("ServerListManager: new groups replace the old ones") +{ + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(0, "Valhalla"), Group(1, "Helheim")}); + manager.SetServerGroups(std::vector{Group(1, "Asgard")}); + + manager.InsertServerGroup(ServerId(0, 1), LowLoad); + manager.InsertServerGroup(ServerId(1, 1), LowLoad); + + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 1); + CHECK(std::wstring(groups[0]->m_szName) == L"Asgard"); +} + +#ifdef _EDITOR +TEST_CASE("ServerListManager: a rebuild names the servers last sent with the new groups") +{ + TestServerListManager manager; + manager.SetServerGroups(std::vector{Group(0, "Valhalla")}); + manager.SetTotalServer(3); + manager.InsertServerGroup(ServerId(0, 1), LowLoad); + manager.InsertServerGroup(ServerId(0, 2), LowLoad); + manager.InsertServerGroup(ServerId(1, 1), LowLoad); + REQUIRE(manager.GetReceivedServers().size() == 3); + + manager.SetServerGroups(std::vector{Group(1, "Helheim"), Group(0, "Asgard")}); + manager.RebuildServerGroups(); + + CHECK(manager.GetReceivedServers().size() == 3); + CHECK(manager.GetTotalServer() == 3); + const std::vector groups = GroupsInOrder(manager); + REQUIRE(groups.size() == 2); + CHECK(std::wstring(groups[0]->m_szName) == L"Helheim"); + CHECK(groups[0]->GetServerSize() == 1); + CHECK(std::wstring(groups[1]->m_szName) == L"Asgard"); + REQUIRE(groups[1]->GetServerSize() == 2); + CHECK(std::wstring(groups[1]->GetServerInfo(1)->m_bName) == ChannelName(L"Asgard-2")); +} +#endif