diff --git a/.github/ISSUE_TEMPLATE/bug_report-en.yml b/.github/ISSUE_TEMPLATE/bug_report-en.yml new file mode 100644 index 0000000..e04cf76 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report-en.yml @@ -0,0 +1,191 @@ +name: "🐛 Bug Report (English)" +description: "Something is not working as expected? Help us investigate it." +title: "[Bug]: " +labels: + - "bug" +body: + - type: markdown + attributes: + value: | + ### 🛠️ DedicatedPower Bug Report + + Thank you for helping improve DedicatedPower. Please provide as much technical detail as possible, especially when reporting a Minecraft or Fabric compatibility problem. + + - type: checkboxes + id: checklist + attributes: + label: Pre-flight checklist + description: Please confirm these points before submitting the report. + options: + - label: I have searched the existing issues and this does not appear to be a duplicate. + required: true + - label: I am using a supported and unmodified Minecraft/Fabric installation, or I have listed my other mods below. + required: true + - label: I have included the relevant crash report, latest.log, or server log when available. + required: true + + - type: dropdown + id: area + attributes: + label: Affected area + description: Which part of DedicatedPower is affected? + options: + - Dedicated server GUI startup + - Server console and log panel + - Command input or autocomplete + - Player list or player management + - Player statistics and performance graphs + - Server menu bar + - World settings or game rules + - Backup, export, or server tools + - Mixin or access widener failure + - Build or development environment + - Other + validations: + required: true + + - type: dropdown + id: frequency + attributes: + label: Reproduction frequency + description: How often can you reproduce the problem? + options: + - Always (100%) + - Often (more than 50%) + - Sometimes (less than 50%) + - Rarely (once or twice) + - I cannot reproduce it consistently + validations: + required: true + + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the problem clearly and explain what you were doing when it occurred. + placeholder: "When I start the dedicated server, ..." + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Provide precise steps that allow someone else to reproduce the issue. + placeholder: | + 1. Install Minecraft ... + 2. Install Fabric Loader ... + 3. Add DedicatedPower ... + 4. Start the dedicated server ... + 5. Open or use ... + value: | + 1. + 2. + 3. + 4. + validations: + required: true + + - type: textarea + id: expected-actual + attributes: + label: Expected behavior vs. actual behavior + description: What did you expect to happen, and what happened instead? + placeholder: | + Expected: + + Actual: + validations: + required: true + + - type: markdown + attributes: + value: "---" + + - type: input + id: dedicatedpower-version + attributes: + label: DedicatedPower version or commit + description: Include the release version, build artifact version, or Git commit you are using. + placeholder: "26.2-1.0.0-beta or abc1234" + validations: + required: true + + - type: input + id: minecraft-version + attributes: + label: Minecraft version + description: Include the exact version, for example 26.2. + placeholder: "26.2" + validations: + required: true + + - type: input + id: loader-version + attributes: + label: Fabric Loader version + placeholder: "0.19.3" + validations: + required: true + + - type: input + id: fabric-api-version + attributes: + label: Fabric API version + description: Write "not installed" if you are testing without Fabric API. + placeholder: "0.152.0+26.2" + validations: + required: true + + - type: input + id: java-version + attributes: + label: Java version + description: Include the full output of java -version when possible. + placeholder: "Java 25.0.x" + validations: + required: true + + - type: dropdown + id: operating-system + attributes: + label: Operating system + options: + - Windows 11 + - Windows 10 + - macOS + - Linux + - Other + validations: + required: true + + - type: textarea + id: mods + attributes: + label: Other installed mods + description: List all other mods and their versions, or write "DedicatedPower only". + placeholder: | + - Mod name - version + - Mod name - version + + - type: textarea + id: logs + attributes: + label: Logs or crash report + description: Paste the relevant log or crash report here and remove sensitive information such as IP addresses or passwords. + placeholder: | + Paste the relevant log or crash report here. + render: shell + + - type: textarea + id: screenshots + attributes: + label: Screenshots or recordings + description: Drag and drop screenshots or recordings here if they help demonstrate the issue. + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add anything else that may help, such as recent updates, configuration changes, or a minimal reproduction. + placeholder: "This started after ..." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..399c4ca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: "💬 GitHub Discussions" + url: "https://github.com/PiBOH/DedicatedPower/discussions" + about: "Use Discussions for questions, setup help, compatibility questions, and general support." diff --git a/.github/ISSUE_TEMPLATE/feature_request-en.yml b/.github/ISSUE_TEMPLATE/feature_request-en.yml new file mode 100644 index 0000000..b38bee3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request-en.yml @@ -0,0 +1,83 @@ +name: "🚀 Feature Request (English)" +description: "Have an idea to improve DedicatedPower? Tell us about it." +title: "[Feature]: " +labels: + - "enhancement" +body: + - type: markdown + attributes: + value: | + ### 💡 DedicatedPower Feature Proposal + + We welcome practical ideas that make dedicated Minecraft server management easier, safer, and more powerful. + + - type: checkboxes + id: checklist + attributes: + label: Pre-flight checklist + description: Please check these points before submitting your proposal. + options: + - label: I have searched the existing issues and this idea has not already been proposed. + required: true + - label: This proposal is specifically related to DedicatedPower or its dedicated server workflow. + required: true + - label: I have separated this proposal from unrelated bugs or general Minecraft support questions. + required: true + + - type: dropdown + id: category + attributes: + label: Feature category + description: Which area would this proposal improve? + options: + - Dedicated server GUI + - Console, logs, or command autocomplete + - Player list or player administration + - Performance monitoring or statistics + - Server, world, or game rule management + - Backups, exports, or server maintenance + - Minecraft 26.2 or future-version compatibility + - Accessibility or usability + - Developer tooling or documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Explain the current limitation, workflow problem, or use case behind your proposal. + placeholder: "When managing a dedicated server, it is difficult to ..." + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like to see? + description: Describe the feature in detail, including how it should work from a user's perspective. + placeholder: "I would like DedicatedPower to ..." + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe any alternative solution, workaround, command, or existing mod feature you have considered. + placeholder: "I have considered ..." + + - type: textarea + id: implementation + attributes: + label: Implementation ideas + description: If you have technical ideas, mention relevant Minecraft, Fabric, Swing, mixin, or API details. This section is optional. + placeholder: "A possible implementation could use ..." + + - type: textarea + id: mockups + attributes: + label: Mockups, examples, or extra context + description: Drag and drop screenshots, sketches, links, command examples, or other supporting material here. + placeholder: "Example layout, screenshot, or reference: ..." diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..adf746b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,50 @@ +--- +name: "PR Template" +about: "Template for creating Pull Requests" +--- + +## 📝 Description + +Provide a clear and concise description of the changes introduced by this Pull Request. + +--- + +## 🔗 Linked Issues + +Specify any issues linked to this Pull Request (use `Closes #X` or `Fixes #X` to auto-close them). + +- Closes # + +--- + +## 🛠️ Type of Change + +Select the type of change introduced by this PR: + +- [ ] 🐛 **Bug Fix** (non-breaking change which fixes an issue) +- [ ] ✨ **New Feature** (non-breaking change which adds functionality) +- [ ] 🚀 **Optimization / Performance** (improvements in code execution) +- [ ] 🎨 **UI / Styling** (visual improvements without logic changes) +- [ ] 📄 **Documentation** (changes or additions to documentation) +- [ ] 🧪 **Testing** (adding or fixing tests) + +--- + +## 🧪 How Has This Been Tested? + +Describe the tests you ran to verify your changes. Provide instructions so we can reproduce. + +- [ ] **Local Build**: Tested locally using `./gradlew build`. +- [ ] **Dedicated Server**: Verified the mod loads on a dedicated Minecraft server. + +--- + +## ✅ Checklist + +Please check all the boxes that apply to your PR: + +- [ ] My code follows the code style guidelines of this project. +- [ ] I have performed a self-review of my own code. +- [ ] I have commented my code, particularly in hard-to-understand areas. +- [ ] I have updated the documentation / `docs/` folder accordingly. +- [ ] My changes generate no new warnings or build errors. diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..94261fe --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,202 @@ +name: Auto Release + +on: + push: + branches: + - mc-26.2 + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 26.2-1.0.3-beta). Leave empty to use version.txt.' + required: false + default: '' + type: string + +permissions: + contents: write + +jobs: + release: + name: Build and publish release + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'v') + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: microsoft + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Parse release version and channel + id: release + shell: bash + env: + COMMIT_TITLE: ${{ github.event.head_commit.message }} + VERSION_INPUT: ${{ inputs.version }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + + # On a push the first line of the commit message is used; on a manual + # dispatch the version input is used (falling back to version.txt). + # Examples: + # v26.2-1.0.0 -> stable release + # v26.2-1.0.0-beta -> beta prerelease + # v26.2-1.0.0-alpha.1 -> alpha prerelease + # v26.2-1.0.0-rc1 -> release-candidate prerelease + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + RELEASE_INPUT="${VERSION_INPUT}" + if [[ -z "$RELEASE_INPUT" ]]; then + RELEASE_INPUT="$(cat version.txt)" + fi + else + RELEASE_INPUT="${COMMIT_TITLE%%$'\n'*}" + fi + RELEASE_INPUT="${RELEASE_INPUT#v}" + RELEASE_INPUT="${RELEASE_INPUT// /-}" + RELEASE_INPUT="${RELEASE_INPUT,,}" + + if [[ "$RELEASE_INPUT" =~ ^(.+)-(stable|beta|alpha|rc)([.-]?[0-9]+)?$ ]]; then + BASE_VERSION="${BASH_REMATCH[1]}" + CHANNEL="${BASH_REMATCH[2]}" + CHANNEL_SUFFIX="${BASH_REMATCH[3]:-}" + else + BASE_VERSION="$RELEASE_INPUT" + CHANNEL="stable" + CHANNEL_SUFFIX="" + fi + + if [[ ! "$BASE_VERSION" =~ ^[0-9]+([.][0-9]+)*(-[0-9]+([.][0-9]+)*)?$ ]]; then + echo "Invalid release commit format: v${RELEASE_INPUT}" >&2 + echo "Expected examples: v26.2-1.0.0, v26.2-1.0.0-beta, v26.2-1.0.0-alpha.1, or v26.2-1.0.0-rc1" >&2 + exit 1 + fi + + if [[ "$CHANNEL" == "stable" && -z "$CHANNEL_SUFFIX" ]]; then + VERSION="$BASE_VERSION" + PRERELEASE=false + elif [[ "$CHANNEL" == "stable" ]]; then + VERSION="${BASE_VERSION}-stable${CHANNEL_SUFFIX}" + PRERELEASE=false + else + VERSION="${BASE_VERSION}-${CHANNEL}${CHANNEL_SUFFIX}" + PRERELEASE=true + fi + + # The release tag has no "v" prefix (e.g. 26.2-1.0.0-beta), + # even though the triggering commit message starts with "v". + TAG="${VERSION}" + RELEASE_NAME="DedicatedPower ${VERSION}" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "name=${RELEASE_NAME}" >> "$GITHUB_OUTPUT" + echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" + echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT" + + echo "Version: ${VERSION}" + echo "Channel: ${CHANNEL}" + echo "Prerelease: ${PRERELEASE}" + echo "Tag: ${TAG}" + + - name: Extract changelog section + id: changelog + shell: bash + run: | + set -euo pipefail + + VERSION="${{ steps.release.outputs.version }}" + + # Grab the Keep a Changelog section for this version (## [] ...) + if [[ ! -f CHANGELOG.md ]]; then + SECTION="" + else + SECTION="$(awk -v v="$VERSION" ' + index($0, "## [" v "]") == 1 { found = 1; print; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md)" + fi + + if [[ -z "$SECTION" ]]; then + SECTION="*No changelog entry found for version ${VERSION}.*" + fi + + # EOF-delimited output so multiline content survives workflow outputs + { + echo "section<> "$GITHUB_OUTPUT" + + - name: Check for an existing release tag + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + + if git ls-remote --exit-code --refs origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "A release tag named ${RELEASE_TAG} already exists. Use a new version in the commit message." >&2 + exit 1 + fi + + - name: Build mod + run: ./gradlew clean build -Pmod_version="${{ steps.release.outputs.version }}" + + - name: Select release JAR + id: artifact + shell: bash + run: | + set -euo pipefail + + mapfile -t JARS < <(find build/libs -maxdepth 1 -type f -name '*.jar' \ + ! -name '*-sources.jar' \ + ! -name '*-dev.jar' \ + -print | sort) + + if [[ "${#JARS[@]}" -ne 1 ]]; then + echo "Expected exactly one release JAR, found ${#JARS[@]}" >&2 + printf '%s\n' "${JARS[@]}" + ls -la build/libs + exit 1 + fi + + JAR="${JARS[0]}" + echo "path=${JAR}" >> "$GITHUB_OUTPUT" + echo "Selected artifact: ${JAR}" + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release.outputs.tag }} + name: ${{ steps.release.outputs.name }} + target_commitish: ${{ github.sha }} + prerelease: ${{ steps.release.outputs.prerelease }} + generate_release_notes: true + fail_on_unmatched_files: true + body: | + ## DedicatedPower ${{ steps.release.outputs.version }} + + **Release channel:** `${{ steps.release.outputs.channel }}` + **Minecraft target:** See the Minecraft version declared in the project metadata. + + ${{ steps.changelog.outputs.section }} + + --- + + This release was built automatically from commit `${{ github.sha }}`. + files: ${{ steps.artifact.outputs.path }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fc3827..713ef59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,10 @@ # against bad commits. name: build -on: [pull_request, push] +on: + pull_request: + push: + workflow_dispatch: jobs: build: @@ -17,7 +20,7 @@ jobs: - name: setup jdk uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '25' distribution: 'microsoft' - name: make gradle wrapper executable run: chmod +x ./gradlew diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..99369b9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +# Automatically test the project for every push and submitted pull request. +# Runs the full Gradle build (compilation, access widener validation, mixin +# processing and the test task) and then verifies the structure of the built +# mod jar so packaging problems are caught early. + +name: test +on: + pull_request: + push: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - name: checkout repository + uses: actions/checkout@v4 + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'microsoft' + - name: make gradle wrapper executable + run: chmod +x ./gradlew + - name: build and run tests + run: ./gradlew build + - name: verify mod jar structure + shell: bash + run: | + set -euo pipefail + + JAR=$(find build/libs -maxdepth 1 -type f -name '*.jar' \ + ! -name '*-sources.jar' \ + ! -name '*-dev.jar' \ + -print | head -1) + + test -n "$JAR" + echo "Inspecting: $JAR" + + unzip -l "$JAR" | grep -E 'fabric.mod.json' >/dev/null + echo "OK: fabric.mod.json present" + + unzip -l "$JAR" | grep -E 'dedicatedpower.mixins.json' >/dev/null + echo "OK: dedicatedpower.mixins.json present" + + unzip -l "$JAR" | grep -E 'dedicatedpower.accesswidener' >/dev/null + echo "OK: access widener present" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aaf407f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,253 @@ +# Changelog + +## [26.2-1.2.17] - 2026-08-11 + +### Fixed +- Fixed recursively corrupted MOTD section signs such as `§` being displayed instead of `§` +- Made mojibake repair iterative and idempotent across the MOTD preview, editor, history, copy, and server-properties save paths + +## [26.2-1.2.16] - 2026-08-11 + +### Fixed +- Fixed command input characters being reordered intermittently (for example, `reload` becoming `eloadr` or `opengui` becoming `penguio`) by removing asynchronous autocomplete updates during ordinary typing +- Command suggestions now run only when explicitly requested with `TAB` and stale suggestions are invalidated when the text or caret changes +- Removed the invalid `StyledDocument` cast from command validation for the `JTextField` command input + +## [26.2-1.2.15] - 2026-08-11 + +### Fixed +- Fixed MOTDs containing the UTF-8 mojibake sequence `§` being displayed or saved instead of the Minecraft section sign `§` +- Repaired affected MOTD history entries automatically when they are loaded or added + +## [26.2-1.2.14] - 2026-08-11 + +### Added +- Added MOTD editor buttons for all Minecraft legacy colors (`§0`-`§f`) +- Added the missing `Obfuscated` (`§k`) formatting control; Bold, Underline, Italic, Strikethrough, and Reset are now all available +- Added automated coverage for the complete color and formatting MOTD generated by MCTools + +## [26.2-1.2.13] - 2026-08-11 + +### Fixed +- Improved the selected-log context menu by disabling actions when no text is selected and handling unsupported browsers or unavailable clipboards gracefully + +## [26.2-1.2.12] - 2026-08-11 + +### Added +- Added a log context menu with `Copy` and `Search in the web` actions for selected log text + +## [26.2-1.2.11] - 2026-08-11 + +### Added +- Added automated JUnit coverage for MOTD section-sign escapes, newline notation, URL safety, complete example MOTDs, line endings, and the two-line limit +- Extracted MOTD normalization into the dependency-free `MotdTextUtils` helper for reliable testing and reuse + +## [26.2-1.2.10] - 2026-08-11 + +### Fixed +- Fixed MOTD color and formatting codes being displayed literally when pasted as `\\u00A7`, `/u00A7`, `\\n`, or `/n` +- MOTD input is now normalized to Minecraft's canonical `§` formatting codes before preview, history, copy, runtime application, and `server.properties` persistence + +## [26.2-1.2.9] - 2026-08-11 + +### Fixed +- Fixed online player names and details becoming invisible in Dark mode because the custom player-list cell renderer kept the default foreground color +- Updated selected player rows and ping labels to use theme-aware foreground colors for consistent contrast + +## [26.2-1.2.8] - 2026-08-08 + +### Added +- Added `Tools > Export Mod List...` to save the installed mods as a plain text file or as a Markdown table + +### Fixed +- Fixed the server menu bar staying light/white in Dark mode: the native Windows menu bar skin is replaced with the themed renderer (issue [#3](https://github.com/PiBOH/DedicatedPower/issues/3)) +- Fixed the log color palette buttons in `Tools > Appearance...` rendering as light Windows-style buttons in Dark mode (issue [#3](https://github.com/PiBOH/DedicatedPower/issues/3)) +- Submenu arrows, keyboard accelerator text, disabled menu entries, and check/radio menu item icons now follow the theme + +## [26.2-1.2.8-rc7] - 2026-08-08 + +### Fixed +- Fixed the log color palette buttons in `Tools > Appearance...` rendering as light Windows-style buttons in Dark mode: they now use the themed renderer and display the selected log color in both themes (issue [#3](https://github.com/PiBOH/DedicatedPower/issues/3)) + +## [26.2-1.2.8-rc6] - 2026-08-08 + +### Fixed +- Fixed the server menu bar staying light/white in Dark mode: the native Windows menu bar skin is replaced with the themed renderer (issue [#3](https://github.com/PiBOH/DedicatedPower/issues/3)) +- Submenu arrows, keyboard accelerator text, and disabled menu entries now use theme colors in Dark mode +- Checkbox and radio menu items now use the same theme-aware icons as the rest of the GUI + +### Added +- Added `Tools > Export Mod List...` to save the installed mods as a plain text file or as a Markdown table + +## [26.2-1.2.7-rc5] - 2026-08-08 + +### Fixed +- MOTD formatting buttons are never clipped anymore: the toolbar wraps onto additional rows (WrapLayout) instead of scrolling horizontally, so every button is always fully visible + +## [26.2-1.2.7-rc4] - 2026-08-08 + +### Fixed +- Fixed section titles (e.g. `Message`, `Saved MOTDs`, `Theme`, `Log colors`) rendering black in Dark mode: titled borders now use the theme foreground color +- Themed scrollbar thumbs and tracks so they follow Light/Dark mode instead of the native light gray + +## [26.2-1.2.7-rc3] - 2026-08-08 + +### Changed +- Checkbox and radio button glyphs now use custom theme-aware icons that stay readable in both Light and Dark mode instead of the native Look & Feel icons + +## [26.2-1.2.7-rc2] - 2026-08-08 + +### Changed +- The MOTD editor now enforces Minecraft's two-line limit: extra lines are trimmed when typing, pasting, loading history entries, or resetting +- The editor status bar now shows the current line count (`1/2` or `2/2`) next to the character counter + +## [26.2-1.2.7-rc1] - 2026-08-08 + +### Fixed +- Fixed the MOTD editor formatting controls being clipped at narrow window sizes: the button strip no longer wraps into cut-off rows and instead scrolls horizontally +- Applied explicit Light/Dark styling to every MOTD editor control, including the formatting buttons, toolbar strip, editor caret and selection colors, secondary labels, scrollbars, and history checkbox + +## [26.2-1.2.6-beta] - 2026-08-07 + +### Added +- Redesigned `Tools > MOTD Editor...` with a professional server-hosting-style workspace +- Added one-click Minecraft formatting controls for common colors, bold, italic, underline, strikethrough, and reset codes +- Added a live character counter, MOTD status indicator, copy action, reset action, and clearer `Save & Apply` workflow +- Added a server-list-style preview card and richer saved-MOTD history presentation + +### Changed +- Improved MOTD editor spacing, hierarchy, preview readability, and Light/Dark theme integration + +## [26.2-1.2.5-beta] - 2026-08-07 + +### Fixed +- Console output now follows the newest log automatically until the user scrolls upward +- Returning the scrollbar to the bottom automatically re-enables follow-bottom mode + +## [26.2-1.2.4-beta] - 2026-08-07 + +### Fixed +- Fixed command input caret positioning so the first typed character no longer moves to the end of the command +- Deferred autocomplete until after Swing finishes the text mutation and ignored stale asynchronous suggestions + +## [26.2-1.2.3-beta] - 2026-08-07 + +### Fixed +- Preserved custom log palette colors when switching to Dark mode or when Swing refreshes the Appearance dialog controls + +## [26.2-1.2.2-beta] - 2026-08-07 + +### Fixed +- `/opengui` now reuses and brings forward an existing GUI instead of opening duplicate windows +- `Close GUI Only` now hides the existing window so `/opengui` can reopen it later, including when the server was started with `--nogui` + +## [26.2-1.2.1-beta] - 2026-08-07 + +### Added +- Added the DedicatedPower mod version below the Minecraft version in `Help > System Information` + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [26.2-1.2.0-beta] - 2026-08-07 + +### Added +- Added `/opengui`, which explicitly opens the DedicatedPower GUI even when the server was started with `--nogui` (when a graphical environment is available) +- Added a `Network` menu showing the Java server port and detecting the Bedrock port from common Geyser-Fabric configuration paths +- Added `Tools > MOTD Editor...` with live preview, Minecraft legacy color-code support, persistent MOTD history, history enable/disable, selected-entry deletion, and clear-history controls + +### Fixed +- Applied explicit Basic Swing menu and popup renderers, including menus opened after the initial GUI theme pass, so menu entries and submenus follow Light/Dark mode more reliably + +## [26.2-1.1.2-beta] - 2026-08-07 + +### Fixed +- Fixed ordinary Swing buttons still using the native light renderer in dark mode by adding explicit themed rendering for normal, hover, and pressed button states + +## [26.2-1.1.1-beta] - 2026-08-07 + +### Fixed +- Completed theme switching for existing buttons and controls, including checkboxes, radio buttons, menu items, spinners, lists, tables, popups, and scrollbars in the GUI and open dialogs + +## [26.2-1.1.0-beta] - 2026-08-07 + +### Added +- Added a persistent Light/Dark appearance switch under `Tools > Appearance...` +- Added a configurable log color palette for INFO, WARN, ERROR, DEBUG, and CHAT messages using native Swing color pickers +- Appearance settings are stored in `config/dedicatedpower-gui.properties` and restored on the next server start + +### Changed +- Integrated the appearance controls from issue [#1](https://github.com/PiBOH/DedicatedPower/issues/1) and issue [#2](https://github.com/PiBOH/DedicatedPower/issues/2), with the theme applied to the log panel, player list, statistics panel, menus, and dialogs +- The appearance and GUI configuration work is documented alongside the related [PiBOH/jarock](https://github.com/PiBOH/jarock) project + +## [26.2-1.0.9] - 2026-08-07 + +### Added +- Added a `Done!` ASCII ready banner when the server finishes starting, working both with and without Geyser +- Inspired by Jarock's `server-ready-banner.txt`: [PiBOH/jarock](https://github.com/PiBOH/jarock) + +## [26.2-1.0.8] - 2026-08-07 + +### Fixed +- Moved shared GUI state and logging outside the mixin package and split the GUI mixins into standalone classes to prevent Fabric mixin class-loading crashes during server startup + +## [26.2-1.0.7] - 2026-08-07 + +### Fixed +- The enhanced server GUI is no longer forced open when the server is launched with `--nogui` (or in a headless environment), matching vanilla behaviour + +## [26.2-1.0.6] - 2026-08-07 + +### Fixed +- Fixed the top menu bar not appearing: menus are now built synchronously and the frame is always revalidated/repainted after install +- Fixed very slow shutdown with "Close GUI and Server": the server now stops on a background thread so the GUI stays responsive and the process exits reliably once worlds are saved + +## [26.2-1.0.5] - 2026-08-07 + +### Added +- Help menu now links back to the original project repository (GitHub and Modrinth page) + +### Changed +- Closing the GUI window (X button) now asks whether to close only the GUI or also shut down the server +- Report Bug now points back to the original repository issue tracker + +## [26.2-1.0.4-beta] - 2026-08-07 + +### Fixed +- Corrected `fabric.mod.json` metadata: homepage now points to Modrinth and sources to the actual GitHub repository + +## [26.2-1.0.3-beta] - 2026-08-06 + +### Added +- `workflow_dispatch` trigger on all GitHub Actions workflows (Auto Release, Build, Test) +- Auto Release tag no longer includes "v" prefix + +## [26.2-1.0.2-beta] - 2026-08-06 + +### Fixed +- GUI blank screen on startup fixed for Minecraft 26.2 +- `buildChatPanel` mixin: populate `logAppenderThread` field to prevent NPE that left the window blank + +## [26.2-1.0.1-beta] - 2026-08-06 + +### Changed +- Ported to Minecraft 26.2 (Chaos Cubed Update) +- Updated to Fabric Loader 0.19.3+ +- Updated to Java 25 + +## [26.2-1.0.0-beta] - 2026-08-06 + +### Added +- Initial release ported to Minecraft 26.2 +- Enhanced server GUI with stats panel (TPS, memory, entity/chunk counts) +- Real-time player list with heads, ping, gamemode, and OP indicators +- Advanced log panel with filtering, search, and command auto-completion +- Server menu: properties editor, whitelist manager, difficulty/gamemode controls +- World menu: time/weather controls, game rules editor, world border settings +- Performance menu: entity clearing, garbage collection, thread dump, performance reports +- Tools menu: command palette, datapack manager, server icon changer +- Help menu: command reference, keyboard shortcuts, system information +- Server backup with progress dialog +- Auto-release workflow via GitHub Actions diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0f5417 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# DedicatedPower DedicatedPower + +

+ DedicatedPower main screen +

+ +

+ Modrinth + Modrinth downloads + Modrinth version + Release + License +

+ +

+ Minecraft 26.2 + Fabric + Java 25+ +

+ +DedicatedPower is a Fabric mod that improves the graphical interface of a Minecraft dedicated server. + +> **Modrinth page:** + + +It replaces the default server administration window with a cleaner interface for monitoring the server, viewing online players, running commands, and accessing common server controls. + +## Compatibility + +- **Minecraft:** Java Edition 26.2 (Chaos Cubed) +- **Platform:** Fabric +- **Environment:** Dedicated servers only +- **Fabric Loader:** 0.19.3 or newer +- **Java:** 25 or newer +- **License:** MIT + +DedicatedPower is not intended for single-player worlds or the integrated server. + +## Links + +- [Modrinth page](https://modrinth.com/mod/server-os) +- [GitHub repository](https://github.com/PiBOH/DedicatedPower) +- [Releases](https://github.com/PiBOH/DedicatedPower/releases) + +## Features + +### Dedicated server interface + +- Custom server GUI for dedicated servers +- Server menu bar with common administration actions +- Quick access to saving worlds, reloading data packs, and stopping the server +- World controls for time and weather +- Command dialog for running server commands + +### Console and commands + +- Live server log output with color-coded log levels +- Dedicated command console panel +- Command execution directly from the GUI +- Command history with the Up and Down arrow keys +- Clearable console output + +### Player management + +- Online player list +- Player name filtering +- Game mode display +- Refreshable player information + +### Server monitoring + +- Current TPS estimate +- Average tick time +- Online player count +- Loaded level count +- Compact statistics panel integrated into the server GUI + +## Installation + +1. Install [Fabric Loader](https://fabricmc.net/use/installer/) for Minecraft 26.2. +2. Install [Fabric API](https://modrinth.com/mod/fabric-api) for Minecraft 26.2. +3. Install Java 25 or newer. +4. Download the latest DedicatedPower release from the [Releases](https://github.com/PiBOH/DedicatedPower/releases) page. +5. Place the DedicatedPower `.jar` file in the server's `mods` folder. +6. Start the dedicated server. + +Always make a backup of your server before installing or updating mods. + +## Usage + +DedicatedPower is loaded automatically when the dedicated server starts. The enhanced server window is created by the mod and can be used to monitor the server and run commands. The console panel streams live server log output with color-coded levels and accepts commands entered through the GUI. + +Use the **Server**, **World**, and **Tools** menus to access the available controls. Commands should be entered without the leading slash, for example: + +```text +save-all +``` + +## Building from source + +### Requirements + +- JDK 25 +- Git +- Internet access for Gradle and Minecraft dependencies + +### Build commands + +Clone the repository and build the mod with the Gradle wrapper: + +```bash +git clone https://github.com/PiBOH/DedicatedPower.git +cd DedicatedPower +./gradlew build +``` + +On Windows, use: + +```bat +gradlew.bat build +``` + +The compiled files are generated in `build/libs/`. + +## Development status + +DedicatedPower is actively being updated for Minecraft 26.2. The project uses the official Minecraft names and the non-remapping Fabric Loom toolchain introduced for newer unobfuscated Minecraft versions. + +The project currently focuses on the dedicated server GUI and administration workflow. Some advanced features from earlier versions may be reintroduced or expanded in future updates. + +## Reporting issues + +Before opening an issue, make sure that: + +- You are using Minecraft 26.2. +- You are using Java 25 or newer. +- You are using compatible Fabric Loader and Fabric API versions. +- You have searched existing issues. +- You have included the relevant server log or crash report. + +Use the repository's [English bug report form](https://github.com/PiBOH/DedicatedPower/issues/new/choose) for bug reports. Use [GitHub Discussions](https://github.com/PiBOH/DedicatedPower/discussions) for general questions and setup help. + +## Authors + +- SuperSirvu +- PiBOH + +## License + +DedicatedPower is licensed under the [MIT License](LICENSE). + +Enjoy running your dedicated server. diff --git a/build.gradle b/build.gradle index 6eb6b41..745af98 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'net.fabricmc.fabric-loom' version "${loom_version}" id 'maven-publish' } @@ -12,21 +12,28 @@ base { repositories { mavenCentral() - maven { url 'https://maven.fabricmc.net/' } maven { url 'https://maven.terraformersmc.com/' } } dependencies { // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" // Fabric API. This is technically optional, but you probably want it anyway. - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation platform("org.junit:junit-bom:5.11.4") + testImplementation "org.junit.jupiter:junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } + +tasks.test { + useJUnitPlatform() +} + loom { accessWidenerPath = file("src/main/resources/dedicatedpower.accesswidener") } @@ -40,7 +47,7 @@ processResources { } tasks.withType(JavaCompile).configureEach { - it.options.release = 21 + it.options.release = 25 } java { @@ -49,8 +56,8 @@ java { // If you remove this line, sources will not be generated. withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } jar { diff --git a/gradle.properties b/gradle.properties index 100e925..7e4b16a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,15 +7,14 @@ org.gradle.configuration-cache=false # Fabric Properties # check these on https://fabricmc.net/develop -minecraft_version=1.21.11 -yarn_mappings=1.21.11+build.4 -loader_version=0.18.4 -loom_version=1.14-SNAPSHOT +minecraft_version=26.2 +loader_version=0.19.3 +loom_version=1.17-SNAPSHOT # Mod Properties -mod_version=1.21.11-1.0 +mod_version=26.2-1.2.17 maven_group=net.supersirvu archives_base_name=dedicatedpower # Dependencies -fabric_api_version=0.141.1+1.21.11 \ No newline at end of file +fabric_api_version=0.156.0+26.2 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..359758f Binary files /dev/null and b/icon.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..ecc0c98 Binary files /dev/null and b/logo.png differ diff --git a/src/main/java/net/supersirvu/DedicatedPower.java b/src/main/java/net/supersirvu/DedicatedPower.java index 01e2288..350f960 100644 --- a/src/main/java/net/supersirvu/DedicatedPower.java +++ b/src/main/java/net/supersirvu/DedicatedPower.java @@ -6,7 +6,18 @@ package net.supersirvu; +import com.mojang.brigadier.CommandDispatcher; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.chat.Component; +import net.minecraft.server.dedicated.DedicatedServer; +import net.minecraft.server.gui.MinecraftServerGui; + +import java.awt.GraphicsEnvironment; +import javax.swing.JFrame; +import javax.swing.SwingUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,8 +27,79 @@ public class DedicatedPower implements ModInitializer { public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + /* + * Inspired by Jarock's server-ready-banner.txt: + * https://github.com/PiBOH/jarock + */ + private static final String READY_BANNER = " ▄▄\n" + + "████▄ ▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄ ██\n" + + "██ ██ ██▀██ ███▄██ ██▄▄ ██\n" + + "████▀ ▀███▀ ██ ▀██ ██▄▄▄ ▄▄"; + @Override public void onInitialize() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + registerCommands(dispatcher)); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + // Queue the banner after all SERVER_STARTED callbacks have run. This keeps + // it after optional integrations such as Geyser finish their startup work. + server.execute(() -> LOGGER.info("\n{}\nThe DedicatedPower server has finished loading.\n", READY_BANNER)); + }); + } + + private static void registerCommands(CommandDispatcher dispatcher) { + dispatcher.register(net.minecraft.commands.Commands.literal("opengui") + .requires(net.minecraft.commands.Commands.hasPermission(net.minecraft.commands.Commands.LEVEL_GAMEMASTERS)) + .executes(context -> { + if (!(context.getSource().getServer() instanceof DedicatedServer server)) { + context.getSource().sendFailure(Component.literal("DedicatedPower GUI is available only on a dedicated server.")); + return 0; + } + if (GraphicsEnvironment.isHeadless()) { + context.getSource().sendFailure(Component.literal("DedicatedPower GUI cannot open because this server is running in a headless environment.")); + return 0; + } + + // Reuse the existing enhanced frame when it was hidden with + // "Close GUI Only". This avoids duplicate windows and works both + // for normal startup and for servers launched with --nogui. + if (showExistingFrame()) { + context.getSource().sendSuccess(() -> Component.literal("The DedicatedPower GUI is already open; bringing it to the front."), false); + return 1; + } + if (!ServerGuiState.beginOpening()) { + context.getSource().sendSuccess(() -> Component.literal("The DedicatedPower GUI is already opening."), false); + return 1; + } + + // --nogui suppresses automatic startup only. DedicatedServer.showGui() + // is the explicit opt-in path used by this command and is idempotent. + try { + // Call the frame factory directly when the old frame was disposed. + // This also bypasses DedicatedServer's stale private gui field. + MinecraftServerGui.showFrameFor(server); + } catch (Throwable throwable) { + ServerGuiState.endOpening(); + LOGGER.error("Failed to open the DedicatedPower GUI from /opengui", throwable); + } + context.getSource().sendSuccess(() -> Component.literal("Opening the DedicatedPower GUI..."), false); + return 1; + })); + } + private static boolean showExistingFrame() { + JFrame existingFrame = ServerGuiState.getFrame(); + if (existingFrame == null) { + return false; + } + SwingUtilities.invokeLater(() -> { + if (existingFrame.isDisplayable()) { + existingFrame.setVisible(true); + existingFrame.toFront(); + existingFrame.requestFocus(); + } + }); + return true; } } \ No newline at end of file diff --git a/src/main/java/net/supersirvu/ServerGuiState.java b/src/main/java/net/supersirvu/ServerGuiState.java new file mode 100644 index 0000000..2b2c5af --- /dev/null +++ b/src/main/java/net/supersirvu/ServerGuiState.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 SuperSirvu + * + * Licensed under the MIT License. + */ + +package net.supersirvu; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.JFrame; + +/** Shared state used by the server GUI mixins without referencing the mixin package. */ +public final class ServerGuiState { + public static final Logger LOGGER = LoggerFactory.getLogger("DedicatedPower"); + + /** True when the server was launched with --nogui; the GUI must not be forced. */ + public static volatile boolean noGuiRequested; + + /** The enhanced GUI frame, retained when the user chooses to close only the GUI. */ + private static volatile JFrame frame; + private static volatile boolean opening; + + private ServerGuiState() { + } + + public static boolean isNoGuiRequested() { + return noGuiRequested; + } + + public static JFrame getFrame() { + return frame; + } + + public static synchronized void setFrame(JFrame frame) { + ServerGuiState.frame = frame; + } + + public static synchronized boolean beginOpening() { + if (opening) { + return false; + } + opening = true; + return true; + } + + public static synchronized void endOpening() { + opening = false; + } + + public static boolean isOpening() { + return opening; + } +} diff --git a/src/main/java/net/supersirvu/gui/EnhancedLogPanel.java b/src/main/java/net/supersirvu/gui/EnhancedLogPanel.java index 4639d88..1890faf 100644 --- a/src/main/java/net/supersirvu/gui/EnhancedLogPanel.java +++ b/src/main/java/net/supersirvu/gui/EnhancedLogPanel.java @@ -11,12 +11,12 @@ import com.mojang.brigadier.StringReader; import com.mojang.brigadier.suggestion.Suggestion; import com.mojang.brigadier.suggestion.Suggestions; -import net.minecraft.server.command.ServerCommandSource; -import net.minecraft.server.dedicated.MinecraftDedicatedServer; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.dedicated.DedicatedServer; import javax.swing.*; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; +import javax.swing.event.CaretEvent; +import javax.swing.event.CaretListener; import javax.swing.text.BadLocationException; import javax.swing.text.Style; import javax.swing.text.StyleConstants; @@ -26,21 +26,30 @@ import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.awt.datatransfer.StringSelection; import java.io.FileWriter; import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.*; import java.util.List; import java.util.concurrent.CompletableFuture; -public class EnhancedLogPanel extends JPanel { - private final MinecraftDedicatedServer server; +public class EnhancedLogPanel extends JPanel implements ThemeManager.ThemeListener { + private final DedicatedServer server; // Components private final JTextPane logTextPane; private final JTextField commandInput; private final JScrollPane logScrollPane; private final StyledDocument logDocument; + private boolean followLogBottom = true; + private boolean updatingLogDisplay; + private boolean autoScrollPending; + private boolean programmaticScroll; + private long scrollGeneration; // Command suggestion private JWindow suggestionWindow; @@ -48,7 +57,7 @@ public class EnhancedLogPanel extends JPanel { private JList suggestionList; private CompletableFuture pendingSuggestions; private Suggestions currentSuggestions; - private CommandDocumentListener documentListener; + private long suggestionGeneration; // Command history private final List commandHistory = new ArrayList<>(); @@ -71,34 +80,61 @@ public class EnhancedLogPanel extends JPanel { private JPanel controlPanel; private JTextField searchField; - public EnhancedLogPanel(MinecraftDedicatedServer server) { + public EnhancedLogPanel(DedicatedServer server) { this.server = server; + ThemeManager themeManager = ThemeManager.getInstance(); + themeManager.addListener(this); + setLayout(new BorderLayout()); - setBackground(new Color(240, 240, 240)); + setBackground(themeManager.getPanelBackground()); // Create log display FIRST (before initializing styles) logTextPane = new JTextPane(); logTextPane.setEditable(false); logTextPane.setFont(new Font("Monospaced", Font.PLAIN, 12)); - logTextPane.setBackground(Color.WHITE); + logTextPane.setBackground(themeManager.getInputBackground()); + logTextPane.setForeground(themeManager.getForeground()); logDocument = logTextPane.getStyledDocument(); + installLogContextMenu(); // NOW initialize styles (after logTextPane is created) initializeStyles(); logScrollPane = new JScrollPane(logTextPane); logScrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); + JScrollBar verticalScrollBar = logScrollPane.getVerticalScrollBar(); + verticalScrollBar.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + if (!programmaticScroll) { + autoScrollPending = false; + scrollGeneration++; + updateFollowLogBottom(); + } + } + }); + logScrollPane.addMouseWheelListener(event -> { + if (!programmaticScroll) { + autoScrollPending = false; + scrollGeneration++; + SwingUtilities.invokeLater(EnhancedLogPanel.this::updateFollowLogBottom); + } + }); + verticalScrollBar.addAdjustmentListener(event -> { + if (!programmaticScroll && !autoScrollPending) { + updateFollowLogBottom(); + } + }); // Create command input commandInput = new JTextField(); commandInput.setFont(new Font("Monospaced", Font.PLAIN, 12)); + commandInput.setBackground(themeManager.getInputBackground()); + commandInput.setForeground(themeManager.getForeground()); commandInput.addActionListener(e -> executeCommand()); commandInput.addKeyListener(new CommandInputKeyListener()); - - // Create and store document listener reference - documentListener = new CommandDocumentListener(); - commandInput.getDocument().addDocumentListener(documentListener); + commandInput.addCaretListener(new CommandCaretListener()); // Disable TAB focus traversal so TAB can be used for completion commandInput.setFocusTraversalKeysEnabled(false); @@ -115,6 +151,92 @@ public EnhancedLogPanel(MinecraftDedicatedServer server) { initializeSuggestionWindow(); } + private void installLogContextMenu() { + JPopupMenu contextMenu = new JPopupMenu(); + JMenuItem copyItem = new JMenuItem("Copy"); + JMenuItem searchItem = new JMenuItem("Search in the web"); + + copyItem.addActionListener(event -> copySelectedLogText()); + searchItem.addActionListener(event -> searchSelectedLogText()); + contextMenu.add(copyItem); + contextMenu.add(searchItem); + contextMenu.putClientProperty("dedicatedpower.copyItem", copyItem); + contextMenu.putClientProperty("dedicatedpower.searchItem", searchItem); + + logTextPane.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + showLogContextMenu(event, contextMenu); + } + + @Override + public void mouseReleased(MouseEvent event) { + showLogContextMenu(event, contextMenu); + } + }); + } + + private void showLogContextMenu(MouseEvent event, JPopupMenu contextMenu) { + if (!event.isPopupTrigger()) { + return; + } + + String selectedText = logTextPane.getSelectedText(); + boolean hasSelection = selectedText != null && !selectedText.isBlank(); + ((JMenuItem) contextMenu.getClientProperty("dedicatedpower.copyItem")).setEnabled(hasSelection); + ((JMenuItem) contextMenu.getClientProperty("dedicatedpower.searchItem")).setEnabled(hasSelection); + + ThemeManager.getInstance().applyComponentTheme(contextMenu); + contextMenu.show(logTextPane, event.getX(), event.getY()); + } + + private String getSelectedLogText() { + String selectedText = logTextPane.getSelectedText(); + return selectedText == null ? "" : selectedText; + } + + private void copySelectedLogText() { + String selectedText = getSelectedLogText(); + if (selectedText.isEmpty()) { + return; + } + try { + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new StringSelection(selectedText), null); + } catch (IllegalStateException error) { + JOptionPane.showMessageDialog(this, + "The system clipboard is currently unavailable.", + "Copy", JOptionPane.WARNING_MESSAGE); + } + } + + private void searchSelectedLogText() { + String selectedText = getSelectedLogText().trim(); + if (selectedText.isEmpty()) { + return; + } + + if (!Desktop.isDesktopSupported()) { + JOptionPane.showMessageDialog(this, + "Opening a web browser is not supported on this system.", + "Search in the web", JOptionPane.WARNING_MESSAGE); + return; + } + + try { + Desktop desktop = Desktop.getDesktop(); + if (!desktop.isSupported(Desktop.Action.BROWSE)) { + throw new UnsupportedOperationException("Browser opening is not supported"); + } + String query = URLEncoder.encode(selectedText, StandardCharsets.UTF_8); + desktop.browse(URI.create("https://www.google.com/search?q=" + query)); + } catch (Exception error) { + JOptionPane.showMessageDialog(this, + "Could not open the web browser: " + error.getMessage(), + "Search in the web", JOptionPane.ERROR_MESSAGE); + } + } + public void processLogMessage(String message) { // Parse log level from message LogLevel level = detectLogLevel(message); @@ -138,16 +260,17 @@ private LogLevel detectLogLevel(String message) { private void initializeStyles() { // Log level styles - logStyles.put(LogLevel.INFO, createStyle(new Color(52, 152, 219), false)); - logStyles.put(LogLevel.WARN, createStyle(new Color(243, 156, 18), false)); - logStyles.put(LogLevel.ERROR, createStyle(new Color(231, 76, 60), true)); - logStyles.put(LogLevel.DEBUG, createStyle(new Color(149, 165, 166), false)); - logStyles.put(LogLevel.CHAT, createStyle(new Color(46, 204, 113), false)); + ThemeManager themeManager = ThemeManager.getInstance(); + logStyles.put(LogLevel.INFO, createStyle(themeManager.getLogColor(LogLevel.INFO), false)); + logStyles.put(LogLevel.WARN, createStyle(themeManager.getLogColor(LogLevel.WARN), false)); + logStyles.put(LogLevel.ERROR, createStyle(themeManager.getLogColor(LogLevel.ERROR), true)); + logStyles.put(LogLevel.DEBUG, createStyle(themeManager.getLogColor(LogLevel.DEBUG), false)); + logStyles.put(LogLevel.CHAT, createStyle(themeManager.getLogColor(LogLevel.CHAT), false)); // Command syntax styles - commandStyle = createStyle(new Color(52, 152, 219), true); + commandStyle = createStyle(themeManager.getLogColor(LogLevel.INFO), true); argumentStyle = createStyle(new Color(155, 89, 182), false); - errorStyle = createStyle(new Color(231, 76, 60), false); + errorStyle = createStyle(themeManager.getLogColor(LogLevel.ERROR), false); } private Style createStyle(Color color, boolean bold) { @@ -160,7 +283,7 @@ private Style createStyle(Color color, boolean bold) { private void createControlPanel() { controlPanel = new JPanel(new GridBagLayout()); controlPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - controlPanel.setBackground(new Color(250, 250, 250)); + controlPanel.setBackground(ThemeManager.getInstance().getSurfaceBackground()); GridBagConstraints gbc = new GridBagConstraints(); gbc.fill = GridBagConstraints.HORIZONTAL; @@ -260,8 +383,9 @@ private void initializeSuggestionWindow() { suggestionModel = new DefaultListModel<>(); suggestionList = new JList<>(suggestionModel); suggestionList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - suggestionList.setBackground(Color.WHITE); - suggestionList.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + suggestionList.setBackground(ThemeManager.getInstance().getInputBackground()); + suggestionList.setForeground(ThemeManager.getInstance().getForeground()); + suggestionList.setBorder(BorderFactory.createLineBorder(ThemeManager.getInstance().getBorderColor())); suggestionList.setFont(new Font("Monospaced", Font.PLAIN, 11)); suggestionList.addMouseListener(new MouseAdapter() { @@ -280,12 +404,12 @@ public void mouseClicked(MouseEvent e) { } public void appendLog(String message, LogLevel level) { - // Store the log entry LogEntry entry = new LogEntry(message, level); - allLogEntries.add(entry); - // Display if it passes filters + // Keep storage and rendering on the EDT. This prevents a theme refresh + // from racing with a queued log update and displaying an entry twice. SwingUtilities.invokeLater(() -> { + allLogEntries.add(entry); if (shouldDisplayEntry(entry)) { displayLogEntry(entry); } @@ -303,6 +427,11 @@ private boolean shouldDisplayEntry(LogEntry entry) { } private void displayLogEntry(LogEntry entry) { + boolean shouldFollow = followLogBottom; + if (shouldFollow) { + autoScrollPending = true; + } + updatingLogDisplay = true; try { // Add log level tag String levelTag = "[" + (entry.level == LogLevel.CHAT ? "CHAT" : "SERVER LOG") + "] "; @@ -312,6 +441,12 @@ private void displayLogEntry(LogEntry entry) { logDocument.insertString(logDocument.getLength(), entry.message + "\n", logStyles.get(entry.level)); } catch (BadLocationException e) { e.printStackTrace(); + } finally { + updatingLogDisplay = false; + } + if (shouldFollow) { + followLogBottom = true; + scrollLogToBottom(); } } @@ -327,37 +462,49 @@ private void executeCommand() { appendLog("> " + command, LogLevel.INFO); // Execute command - server.enqueueCommand(command, server.getCommandSource()); + server.handleConsoleInput(command, server.createCommandSourceStack()); // Clear input commandInput.setText(""); + suggestionGeneration++; + currentSuggestions = null; hideSuggestions(); } private void updateSuggestions() { + long generation = ++suggestionGeneration; String text = commandInput.getText(); if (text.isEmpty()) { hideSuggestions(); return; } - // Validate command syntax + // Validate command syntax after Swing has completed the document change. validateCommand(text); try { // Get command dispatcher - CommandDispatcher dispatcher = server.getCommandManager().getDispatcher(); + CommandDispatcher dispatcher = server.getCommands().getDispatcher(); // Parse and get suggestions - with bounds checking StringReader reader = new StringReader(text); - ParseResults parse = dispatcher.parse(reader, server.getCommandSource()); + ParseResults parse = dispatcher.parse(reader, server.createCommandSourceStack()); - // Ensure cursor position is within bounds + // Capture the caret together with the text. A completion range is + // valid only if neither the text nor the caret moved while Brigadier + // calculated the asynchronous result. int cursorPos = Math.min(commandInput.getCaretPosition(), text.length()); pendingSuggestions = dispatcher.getCompletionSuggestions(parse, cursorPos); pendingSuggestions.thenAccept(suggestions -> { SwingUtilities.invokeLater(() -> { + // Ignore completions calculated for an older text/caret state. + // They must never overwrite the user's current typing state. + if (generation != suggestionGeneration + || !text.equals(commandInput.getText()) + || cursorPos != commandInput.getCaretPosition()) { + return; + } suggestionModel.clear(); currentSuggestions = suggestions; @@ -376,8 +523,15 @@ private void updateSuggestions() { } }); }).exceptionally(throwable -> { - // Silently handle suggestion errors - SwingUtilities.invokeLater(() -> hideSuggestions()); + // A failed request may also be stale; do not hide a popup created + // for newer input. + SwingUtilities.invokeLater(() -> { + if (generation == suggestionGeneration + && text.equals(commandInput.getText()) + && commandInput.getCaretPosition() == cursorPos) { + hideSuggestions(); + } + }); return null; }); } catch (Exception e) { @@ -388,25 +542,16 @@ private void updateSuggestions() { private void validateCommand(String command) { // Simple validation - check if command exists - CommandDispatcher dispatcher = server.getCommandManager().getDispatcher(); + CommandDispatcher dispatcher = server.getCommands().getDispatcher(); StringReader reader = new StringReader(command); - ParseResults parse = dispatcher.parse(reader, server.getCommandSource()); - - // Highlight syntax in command input - try { - StyledDocument doc = (StyledDocument) commandInput.getDocument(); - Style defaultStyle = doc.addStyle("default", null); - StyleConstants.setForeground(defaultStyle, Color.BLACK); - - if (!parse.getExceptions().isEmpty()) { - // Show error - commandInput.setForeground(Color.RED); - } else { - commandInput.setForeground(Color.BLACK); - } - } catch (Exception e) { - // Ignore styling errors - } + ParseResults parse = dispatcher.parse(reader, server.createCommandSourceStack()); + + // JTextField uses a PlainDocument, so only update its foreground here; + // attempting to cast its document to StyledDocument caused a swallowed + // ClassCastException on every validation. + commandInput.setForeground(parse.getExceptions().isEmpty() + ? ThemeManager.getInstance().getForeground() + : ThemeManager.getInstance().getLogColor(LogLevel.ERROR)); } private void showSuggestions() { @@ -443,19 +588,16 @@ private void applySuggestion() { String after = currentText.substring(end); String newText = before + suggestion.getText() + after; - // CRITICAL: Remove the document listener before modifying text - commandInput.getDocument().removeDocumentListener(documentListener); - - // Set the new text + // Suggestions are requested explicitly (TAB/double-click), so there + // is no document listener to race with this intentional replacement. commandInput.setText(newText); // Position cursor at the end of the inserted suggestion int newCaretPos = start + suggestion.getText().length(); commandInput.setCaretPosition(newCaretPos); - // Re-add the document listener after modification is complete - commandInput.getDocument().addDocumentListener(documentListener); - + suggestionGeneration++; + currentSuggestions = null; hideSuggestions(); commandInput.requestFocus(); } @@ -510,6 +652,27 @@ private void clearLogs() { } } + @Override + public void themeChanged(ThemeManager themeManager) { + SwingUtilities.invokeLater(() -> { + setBackground(themeManager.getPanelBackground()); + controlPanel.setBackground(themeManager.getSurfaceBackground()); + logTextPane.setBackground(themeManager.getInputBackground()); + logTextPane.setForeground(themeManager.getForeground()); + commandInput.setBackground(themeManager.getInputBackground()); + commandInput.setForeground(themeManager.getForeground()); + + for (LogLevel level : LogLevel.values()) { + StyleConstants.setForeground(logStyles.get(level), themeManager.getLogColor(level)); + } + StyleConstants.setForeground(commandStyle, themeManager.getLogColor(LogLevel.INFO)); + StyleConstants.setForeground(errorStyle, themeManager.getLogColor(LogLevel.ERROR)); + refreshLogs(); + revalidate(); + repaint(); + }); + } + private void refreshLogs() { // Clear display try { @@ -518,12 +681,47 @@ private void refreshLogs() { e.printStackTrace(); } - // Re-display all entries that match current filters - for (LogEntry entry : allLogEntries) { + // Re-display the stored entries. Storage and refresh both run on the EDT. + for (LogEntry entry : new ArrayList<>(allLogEntries)) { if (shouldDisplayEntry(entry)) { displayLogEntry(entry); } } + if (followLogBottom) { + scrollLogToBottom(); + } + } + + private void updateFollowLogBottom() { + if (updatingLogDisplay || autoScrollPending || programmaticScroll) { + return; + } + BoundedRangeModel model = logScrollPane.getVerticalScrollBar().getModel(); + int distanceFromBottom = model.getMaximum() - model.getValue() - model.getExtent(); + followLogBottom = distanceFromBottom <= 4; + } + + private void scrollLogToBottom() { + long generation = scrollGeneration; + SwingUtilities.invokeLater(() -> { + if (generation != scrollGeneration || !followLogBottom) { + autoScrollPending = false; + return; + } + JScrollBar scrollBar = logScrollPane.getVerticalScrollBar(); + BoundedRangeModel model = scrollBar.getModel(); + updatingLogDisplay = true; + programmaticScroll = true; + try { + scrollBar.setValue(Math.max(0, model.getMaximum() - model.getExtent())); + } finally { + programmaticScroll = false; + updatingLogDisplay = false; + // Let layout notifications caused by this operation finish before + // automatic/manual scroll detection becomes active again. + SwingUtilities.invokeLater(() -> autoScrollPending = false); + } + }); } private void exportLogs() { @@ -547,7 +745,20 @@ private void exportLogs() { private class CommandInputKeyListener extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { - switch (e.getKeyCode()) { + int keyCode = e.getKeyCode(); + if (keyCode != KeyEvent.VK_UP + && keyCode != KeyEvent.VK_DOWN + && keyCode != KeyEvent.VK_TAB + && keyCode != KeyEvent.VK_ENTER + && keyCode != KeyEvent.VK_ESCAPE) { + // Any normal edit invalidates pending completion ranges. Never + // let an async suggestion act on text that changed afterwards. + suggestionGeneration++; + currentSuggestions = null; + hideSuggestions(); + } + + switch (keyCode) { case KeyEvent.VK_UP: if (suggestionWindow.isVisible() && suggestionModel.size() > 0) { // Navigate suggestions up @@ -612,6 +823,9 @@ public void keyPressed(KeyEvent e) { } private void navigateHistory(int direction) { + suggestionGeneration++; + currentSuggestions = null; + hideSuggestions(); if (commandHistory.isEmpty()) return; historyIndex = Math.max(0, Math.min(commandHistory.size(), historyIndex + direction)); @@ -623,20 +837,14 @@ private void navigateHistory(int direction) { } } - private class CommandDocumentListener implements DocumentListener { + private class CommandCaretListener implements CaretListener { @Override - public void insertUpdate(DocumentEvent e) { - updateSuggestions(); - } - - @Override - public void removeUpdate(DocumentEvent e) { - updateSuggestions(); - } - - @Override - public void changedUpdate(DocumentEvent e) { - updateSuggestions(); + public void caretUpdate(CaretEvent event) { + // Mouse clicks and programmatic history changes can move the caret + // without producing a key event. Invalidate completion ranges then. + suggestionGeneration++; + currentSuggestions = null; + hideSuggestions(); } } @@ -684,4 +892,4 @@ private static class LogEntry { this.level = level; } } -} \ No newline at end of file +} diff --git a/src/main/java/net/supersirvu/gui/EnhancedPlayerListGui.java b/src/main/java/net/supersirvu/gui/EnhancedPlayerListGui.java index aea102b..9750887 100644 --- a/src/main/java/net/supersirvu/gui/EnhancedPlayerListGui.java +++ b/src/main/java/net/supersirvu/gui/EnhancedPlayerListGui.java @@ -9,13 +9,13 @@ import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; import com.mojang.authlib.minecraft.MinecraftProfileTextures; -import net.minecraft.server.BannedPlayerEntry; -import net.minecraft.server.BannedPlayerList; +import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.PlayerConfigEntry; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.text.Text; -import net.minecraft.world.GameMode; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.NameAndId; +import net.minecraft.server.players.UserBanList; +import net.minecraft.server.players.UserBanListEntry; +import net.minecraft.world.level.GameType; import net.supersirvu.DedicatedPower; import javax.imageio.ImageIO; @@ -32,8 +32,9 @@ import java.util.*; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; -public class EnhancedPlayerListGui extends JPanel { +public class EnhancedPlayerListGui extends JPanel implements ThemeManager.ThemeListener { private static final String[] SKINS = new String[]{ "textures/entity/player/slim/alex.png", "textures/entity/player/slim/ari.png", @@ -62,15 +63,17 @@ public class EnhancedPlayerListGui extends JPanel { private int tick; private SortMode sortMode = SortMode.NAME; private String searchFilter = ""; + private PlayerCellRenderer playerCellRenderer; public EnhancedPlayerListGui(MinecraftServer server) { this.server = server; + ThemeManager.getInstance().addListener(this); this.listModel = new DefaultListModel<>(); this.playerList = new JList<>(listModel); - this.headCache = new HashMap<>(); + this.headCache = new ConcurrentHashMap<>(); this.setLayout(new BorderLayout()); - this.setBackground(new Color(240, 240, 240)); + this.setBackground(ThemeManager.getInstance().getPanelBackground()); // Setup player list setupPlayerList(); @@ -84,8 +87,9 @@ public EnhancedPlayerListGui(MinecraftServer server) { scrollPane.setBorder(BorderFactory.createEmptyBorder()); this.add(scrollPane, BorderLayout.CENTER); - // Register tick handler - server.addServerGuiTickable(this::tick); + // Refresh the player list periodically (replaces the removed server GUI tick hook) + Timer refreshTimer = new Timer(1000, e -> tick()); + refreshTimer.start(); // Start async head loading startHeadLoading(); @@ -94,7 +98,7 @@ public EnhancedPlayerListGui(MinecraftServer server) { private void startHeadLoading() { // Periodically check and load missing player heads Timer headLoadTimer = new Timer(2000, e -> { - for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { String uuid = player.getGameProfile().id().toString(); if (!headCache.containsKey(uuid)) { loadPlayerHead(player.getGameProfile()); @@ -111,7 +115,7 @@ private void loadPlayerHead(GameProfile profile) { try { // Get skin URL from Mojang session server MinecraftProfileTextures textures = - server.getApiServices().sessionService().getTextures(profile); + server.services().sessionService().getTextures(profile); if (textures.skin() != null) { MinecraftProfileTexture skinTexture = textures.skin(); @@ -197,9 +201,14 @@ private BufferedImage createFallbackHead() { } private void setupPlayerList() { - playerList.setCellRenderer(new PlayerCellRenderer()); + playerCellRenderer = new PlayerCellRenderer(); + playerList.setCellRenderer(playerCellRenderer); playerList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - playerList.setBackground(Color.WHITE); + ThemeManager themeManager = ThemeManager.getInstance(); + playerList.setBackground(themeManager.getInputBackground()); + playerList.setForeground(themeManager.getForeground()); + playerList.setSelectionBackground(themeManager.getSelectionBackground()); + playerList.setSelectionForeground(themeManager.getForeground()); playerList.setFixedCellHeight(40); // Add right-click context menu @@ -229,7 +238,7 @@ public void mouseClicked(MouseEvent e) { private JPanel createHeaderPanel() { JPanel panel = new JPanel(new BorderLayout()); - panel.setBackground(new Color(250, 250, 250)); + panel.setBackground(ThemeManager.getInstance().getSurfaceBackground()); panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); // Search field @@ -268,7 +277,7 @@ public void focusLost(java.awt.event.FocusEvent e) { banListButton.addActionListener(e -> showBanList()); JPanel controlPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); - controlPanel.setBackground(new Color(250, 250, 250)); + controlPanel.setBackground(ThemeManager.getInstance().getSurfaceBackground()); controlPanel.add(sortButton); controlPanel.add(banListButton); @@ -287,7 +296,7 @@ public void tick() { private void updatePlayerList() { List players = new ArrayList<>(); - for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { String name = player.getGameProfile().name(); // Apply search filter @@ -295,14 +304,15 @@ private void updatePlayerList() { continue; } + NameAndId nameAndId = new NameAndId(player.getGameProfile()); PlayerInfo info = new PlayerInfo( player.getGameProfile(), - player.getPlayerConfigEntry(), - player.networkHandler.getLatency(), - player.interactionManager.getGameMode(), - server.getPlayerManager().isOperator(player.getPlayerConfigEntry()), + nameAndId, + player.connection.latency(), + player.gameMode(), + server.getPlayerList().isOp(nameAndId), player.getHealth(), - player.getHungerManager().getFoodLevel() + player.getFoodData().getFoodLevel() ); players.add(info); } @@ -324,8 +334,8 @@ private void showContextMenu(int x, int y, PlayerInfo player) { kickItem.addActionListener(e -> { String reason = JOptionPane.showInputDialog(this, "Kick reason:", "Kick Player", JOptionPane.QUESTION_MESSAGE); if (reason != null) { - ServerPlayerEntity serverPlayer = server.getPlayerManager().getPlayer(player.profile.id()); - serverPlayer.networkHandler.disconnect(Text.literal(reason)); + ServerPlayer serverPlayer = server.getPlayerList().getPlayer(player.profile.id()); + serverPlayer.connection.disconnect(Component.literal(reason)); } }); @@ -333,13 +343,13 @@ private void showContextMenu(int x, int y, PlayerInfo player) { banItem.addActionListener(e -> { String reason = JOptionPane.showInputDialog(this, "Ban reason:", "Ban Player", JOptionPane.QUESTION_MESSAGE); if (reason != null) { - BannedPlayerList bannedPlayerList = server.getPlayerManager().getUserBanList(); - if (!bannedPlayerList.contains(player.configEntry)) { - BannedPlayerEntry bannedPlayerEntry = new BannedPlayerEntry(player.configEntry, null, "SERVER", null, reason); + UserBanList bannedPlayerList = server.getPlayerList().getBans(); + if (!bannedPlayerList.isBanned(player.nameAndId)) { + UserBanListEntry bannedPlayerEntry = new UserBanListEntry(player.nameAndId, null, "SERVER", null, reason); bannedPlayerList.add(bannedPlayerEntry); - ServerPlayerEntity serverPlayerEntity = server.getPlayerManager().getPlayer(player.profile.id()); + ServerPlayer serverPlayerEntity = server.getPlayerList().getPlayer(player.profile.id()); if (serverPlayerEntity != null) { - serverPlayerEntity.networkHandler.disconnect(Text.translatable("multiplayer.disconnect.banned")); + serverPlayerEntity.connection.disconnect(Component.translatable("multiplayer.disconnect.banned")); } } } @@ -349,16 +359,20 @@ private void showContextMenu(int x, int y, PlayerInfo player) { messageItem.addActionListener(e -> { String message = JOptionPane.showInputDialog(this, "Message:", "Send Message", JOptionPane.QUESTION_MESSAGE); if (message != null) { - server.getPlayerManager().getPlayer(player.profile.id()).sendMessage(Text.literal("SERVER: " + message)); + // Run on the server thread so the GUI is never blocked + server.execute(() -> server.getCommands().performPrefixedCommand( + server.createCommandSourceStack(), + "tell " + player.profile.name() + " SERVER: " + message + )); } }); JMenuItem opItem = new JMenuItem(player.isOp ? "Deop Player" : "Op Player"); opItem.addActionListener(e -> { - if(player.isOp) { - server.getPlayerManager().removeFromOperators(player.configEntry); + if (player.isOp) { + server.getPlayerList().deop(player.nameAndId); } else { - server.getPlayerManager().addToOperators(player.configEntry); + server.getPlayerList().op(player.nameAndId); } }); @@ -368,6 +382,7 @@ private void showContextMenu(int x, int y, PlayerInfo player) { menu.addSeparator(); menu.add(opItem); + ThemeManager.getInstance().applyComponentTheme(menu); menu.show(playerList, x, y); } @@ -376,7 +391,7 @@ private void showPlayerDetails(PlayerInfo player) { "Player: %s\n\nPing: %d ms\nGame Mode: %s\nOperator: %s\nHealth: %.1f\nHunger: %d", player.profile.name(), player.ping, - player.gameMode.getTranslatableName(), + player.gameMode.getName(), player.isOp ? "Yes" : "No", player.health, player.hunger @@ -395,7 +410,7 @@ private void showBanList() { JList banList = new JList<>(banModel); // Get banned players - for (String bannedPlayer : server.getPlayerManager().getUserBanList().getNames()) { + for (String bannedPlayer : server.getPlayerList().getBans().getUserList()) { banModel.addElement(bannedPlayer); } @@ -410,10 +425,10 @@ private void showBanList() { "Confirm Unban", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { - BannedPlayerList bannedPlayerList = server.getPlayerManager().getUserBanList(); - PlayerConfigEntry player = bannedPlayerList.values().stream().filter(entry -> entry.getKey().name().equals(selected)).findFirst().orElseThrow().getKey(); - if (bannedPlayerList.contains(player)) { - bannedPlayerList.remove(player); + UserBanList bannedPlayerList = server.getPlayerList().getBans(); + NameAndId nameAndId = NameAndId.createOffline(selected); + if (bannedPlayerList.isBanned(nameAndId)) { + bannedPlayerList.remove(nameAndId); } banModel.removeElement(selected); } @@ -435,16 +450,16 @@ private void showBanList() { // Player info data class private static class PlayerInfo { final GameProfile profile; - final PlayerConfigEntry configEntry; + final NameAndId nameAndId; final int ping; - final GameMode gameMode; + final GameType gameMode; final boolean isOp; final float health; final int hunger; - PlayerInfo(GameProfile profile, PlayerConfigEntry configEntry, int ping, GameMode gameMode, boolean isOp, float health, int hunger) { + PlayerInfo(GameProfile profile, NameAndId nameAndId, int ping, GameType gameMode, boolean isOp, float health, int hunger) { this.profile = profile; - this.configEntry = configEntry; + this.nameAndId = nameAndId; this.ping = ping; this.gameMode = gameMode; this.isOp = isOp; @@ -479,7 +494,7 @@ private class PlayerCellRenderer extends JPanel implements ListCellRenderer list, PlayerInfo player, + public java.awt.Component getListCellRendererComponent(JList list, PlayerInfo player, int index, boolean isSelected, boolean cellHasFocus) { - // Set background + ThemeManager themeManager = ThemeManager.getInstance(); + Color cellForeground = isSelected ? list.getSelectionForeground() : themeManager.getForeground(); + + // Set background and foreground explicitly. A Swing cell renderer is + // reused outside the normal component hierarchy, so ThemeManager's + // recursive component pass cannot update these labels for us. if (isSelected) { - setBackground(new Color(184, 207, 229)); + setBackground(list.getSelectionBackground()); } else { - setBackground(index % 2 == 0 ? Color.WHITE : new Color(250, 250, 250)); + setBackground(index % 2 == 0 ? themeManager.getInputBackground() : themeManager.getSurfaceBackground()); } + nameLabel.setForeground(cellForeground); + infoLabel.setForeground(isSelected ? cellForeground : themeManager.getMutedForeground()); // Set player head String uuid = player.profile.id().toString(); @@ -529,11 +551,15 @@ public Component getListCellRendererComponent(JList list, nameLabel.setText(player.profile.name()); // Set info (gamemode) - infoLabel.setText("Mode: " + player.gameMode.getTranslatableName()); + infoLabel.setText("Mode: " + player.gameMode.getName()); - // Set ping with color coding + // Set ping with color coding. Use the selection foreground for a + // selected row so status colors never become unreadable on the + // themed selection background. pingLabel.setText(player.ping + " ms"); - if (player.ping < 50) { + if (isSelected) { + pingLabel.setForeground(cellForeground); + } else if (player.ping < 50) { pingLabel.setForeground(new Color(40, 180, 99)); } else if (player.ping < 100) { pingLabel.setForeground(new Color(241, 196, 15)); @@ -557,11 +583,30 @@ public Component getListCellRendererComponent(JList list, } } + @Override + public void themeChanged(ThemeManager themeManager) { + SwingUtilities.invokeLater(() -> { + setBackground(themeManager.getPanelBackground()); + playerList.setBackground(themeManager.getInputBackground()); + playerList.setForeground(themeManager.getForeground()); + playerList.setSelectionBackground(themeManager.getSelectionBackground()); + playerList.setSelectionForeground(themeManager.getForeground()); + themeManager.applyComponentTheme(this); + playerList.setBackground(themeManager.getInputBackground()); + playerList.setSelectionBackground(themeManager.getSelectionBackground()); + playerList.setSelectionForeground(themeManager.getForeground()); + playerCellRenderer = new PlayerCellRenderer(); + playerList.setCellRenderer(playerCellRenderer); + playerList.repaint(); + repaint(); + }); + } + // Sort modes private enum SortMode { NAME("Name", Comparator.comparing(p -> p.profile.name())), PING("Ping", Comparator.comparingInt(p -> p.ping)), - GAMEMODE("Game Mode", Comparator.comparing(p -> p.gameMode.getTranslatableName().getString())); + GAMEMODE("Game Mode", Comparator.comparing(p -> p.gameMode.getName())); private final String displayName; private final Comparator comparator; @@ -584,4 +629,4 @@ SortMode next() { return values[(ordinal() + 1) % values.length]; } } -} \ No newline at end of file +} diff --git a/src/main/java/net/supersirvu/gui/EnhancedPlayerStatsGui.java b/src/main/java/net/supersirvu/gui/EnhancedPlayerStatsGui.java index f0b16fe..e874249 100644 --- a/src/main/java/net/supersirvu/gui/EnhancedPlayerStatsGui.java +++ b/src/main/java/net/supersirvu/gui/EnhancedPlayerStatsGui.java @@ -6,11 +6,10 @@ package net.supersirvu.gui; -import net.minecraft.entity.Entity; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.world.ServerWorld; -import net.minecraft.util.TimeHelper; -import net.minecraft.util.Util; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.TimeUtil; +import net.minecraft.world.entity.Entity; import javax.swing.*; import java.awt.*; @@ -20,13 +19,16 @@ import java.text.DecimalFormatSymbols; import java.util.Locale; -public class EnhancedPlayerStatsGui extends JComponent { - private static final DecimalFormat AVG_TICK_FORMAT = Util.make( - new DecimalFormat("########0.000"), - decimalFormat -> decimalFormat.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(Locale.ROOT)) - ); +public class EnhancedPlayerStatsGui extends JComponent implements ThemeManager.ThemeListener { + private static final DecimalFormat AVG_TICK_FORMAT = createTickFormat(); private static final DecimalFormat NUMBER_FORMAT = new DecimalFormat("#,###"); + private static DecimalFormat createTickFormat() { + DecimalFormat format = new DecimalFormat("########0.000"); + format.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(Locale.ROOT)); + return format; + } + // Graph data private final int[] memoryUsePercentage = new int[256]; private final double[] tickTimeHistory = new double[256]; @@ -57,12 +59,13 @@ public class EnhancedPlayerStatsGui extends JComponent { public EnhancedPlayerStatsGui(MinecraftServer server) { this.server = server; + ThemeManager.getInstance().addListener(this); this.startTime = System.currentTimeMillis(); this.setPreferredSize(new Dimension(456, 246)); this.setMinimumSize(new Dimension(400, 200)); this.timer = new Timer(500, event -> this.update()); this.timer.start(); - this.setBackground(BG_COLOR); + this.setBackground(ThemeManager.getInstance().getPanelBackground()); // Add mouse motion listener for tooltips this.addMouseMotionListener(new MouseMotionAdapter() { @@ -82,16 +85,16 @@ private void update() { long freeMemory = runtime.freeMemory(); // Calculate stats - double avgTickMs = (double)this.server.getAverageNanosPerTick() / TimeHelper.MILLI_IN_NANOS; + double avgTickMs = (double)this.server.getAverageTickTimeNanos() / TimeUtil.NANOSECONDS_PER_MILLISECOND; double tps = Math.min(20.0, 1000.0 / avgTickMs); int entityCount = 0; int chunkCount = 0; - for (ServerWorld world : this.server.getWorlds()) { - for (Entity ignored : world.iterateEntities()) { + for (ServerLevel world : this.server.getAllLevels()) { + for (Entity ignored : world.getAllEntities()) { entityCount++; } - chunkCount += world.getChunkManager().getLoadedChunkCount(); + chunkCount += world.getChunkSource().getLoadedChunksCount(); } // Update text lines @@ -141,7 +144,8 @@ public void paint(Graphics g) { int height = getHeight(); // Background - g2d.setColor(BG_COLOR); + ThemeManager themeManager = ThemeManager.getInstance(); + g2d.setColor(themeManager.getPanelBackground()); g2d.fillRect(0, 0, width, height); // Calculate graph dimensions @@ -175,11 +179,12 @@ private void drawGraph(Graphics2D g2d, String title, int y, int height, double[] int graphX = 10; // Draw grid background - g2d.setColor(Color.WHITE); + ThemeManager themeManager = ThemeManager.getInstance(); + g2d.setColor(themeManager.getGraphBackground()); g2d.fillRect(graphX, y, graphWidth, height); // Draw grid lines - g2d.setColor(new Color(220, 220, 220)); + g2d.setColor(themeManager.getGridColor()); for (int i = 0; i <= 5; i++) { int gridY = y + (height * i / 5); g2d.drawLine(graphX, gridY, graphX + graphWidth, gridY); @@ -190,13 +195,13 @@ private void drawGraph(Graphics2D g2d, String title, int y, int height, double[] } // Draw title - g2d.setColor(Color.BLACK); + g2d.setColor(ThemeManager.getInstance().getForeground()); g2d.setFont(new Font("Arial", Font.BOLD, 11)); g2d.drawString(title, graphX + 5, y - 2); // Draw scale labels g2d.setFont(new Font("Arial", Font.PLAIN, 9)); - g2d.setColor(Color.BLACK); + g2d.setColor(ThemeManager.getInstance().getForeground()); g2d.drawString(String.valueOf((int)maxVal), graphX + graphWidth + 5, y + 10); g2d.drawString(String.valueOf((int)(maxVal / 2)), graphX + graphWidth + 5, y + height / 2 + 5); g2d.drawString(String.valueOf((int)minVal), graphX + graphWidth + 5, y + height); @@ -216,7 +221,7 @@ private void drawGraph(Graphics2D g2d, String title, int y, int height, double[] } // Draw border - g2d.setColor(new Color(100, 100, 100)); + g2d.setColor(ThemeManager.getInstance().getBorderColor()); g2d.drawRect(graphX, y, graphWidth, height); } @@ -274,7 +279,9 @@ private void drawTooltip(Graphics2D g2d, int mx, int my, boolean isMemory) { tooltipX = mx - tooltipWidth - 10; } - g2d.setColor(new Color(255, 255, 255, 200)); + g2d.setColor(ThemeManager.getInstance().isDark() + ? new Color(70, 70, 70, 220) + : new Color(255, 255, 255, 200)); g2d.fillRoundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 5, 5); g2d.setColor(TEXT_COLOR); g2d.drawRoundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 5, 5); @@ -305,7 +312,16 @@ private Color getTickColor(double tickMs) { } } + @Override + public void themeChanged(ThemeManager themeManager) { + SwingUtilities.invokeLater(() -> { + setBackground(themeManager.getPanelBackground()); + revalidate(); + repaint(); + }); + } + public void stop() { this.timer.stop(); } -} \ No newline at end of file +} diff --git a/src/main/java/net/supersirvu/gui/EnhancedServerMenuBar.java b/src/main/java/net/supersirvu/gui/EnhancedServerMenuBar.java index 143f756..cce37e9 100644 --- a/src/main/java/net/supersirvu/gui/EnhancedServerMenuBar.java +++ b/src/main/java/net/supersirvu/gui/EnhancedServerMenuBar.java @@ -6,49 +6,79 @@ package net.supersirvu.gui; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.server.dedicated.MinecraftDedicatedServer; -import net.minecraft.server.world.ServerWorld; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.server.dedicated.DedicatedServer; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.Difficulty; -import net.minecraft.world.GameMode; -import net.minecraft.world.WorldProperties; -import net.minecraft.world.rule.GameRule; -import net.minecraft.world.rule.GameRuleCategory; -import net.minecraft.world.rule.GameRuleVisitor; -import net.minecraft.world.rule.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.gamerules.GameRule; +import net.minecraft.world.level.gamerules.GameRuleCategory; +import net.minecraft.world.level.gamerules.GameRuleTypeVisitor; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.storage.LevelData; import javax.swing.*; +import javax.swing.text.AbstractDocument; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; import java.awt.*; import java.awt.datatransfer.StringSelection; +import java.awt.event.*; import java.io.*; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Comparator; import java.util.Date; +import java.util.EnumMap; +import java.util.Locale; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; public class EnhancedServerMenuBar extends JMenuBar { - private final MinecraftDedicatedServer server; + private static final int MAX_MOTD_LINES = 2; + + private final DedicatedServer server; private final Frame parentFrame; - public EnhancedServerMenuBar(MinecraftDedicatedServer server, Frame parentFrame) { + public EnhancedServerMenuBar(DedicatedServer server, Frame parentFrame) { this.server = server; this.parentFrame = parentFrame; - SwingUtilities.invokeLater(this::createMenus); + // Build the menus synchronously: this class is only ever constructed on the + // EDT (from the GUI install), so populating the bar here guarantees all the + // menus are present before the frame is revalidated and repainted. + createMenus(); } private void createMenus() { add(createServerMenu()); add(createWorldMenu()); + add(createNetworkMenu()); add(createPerformanceMenu()); add(createToolsMenu()); add(createHelpMenu()); } + /** + * Runs a console command on the server thread, exactly like the vanilla + * console input handler, so heavy commands never block the Swing GUI. + */ + private void runCommand(String command) { + server.execute(() -> server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command)); + } + // ==================== SERVER MENU ==================== private JMenu createServerMenu() { JMenu serverMenu = new JMenu("Server"); @@ -66,8 +96,8 @@ private JMenu createServerMenu() { JMenu difficultyMenu = new JMenu("Difficulty"); ButtonGroup difficultyGroup = new ButtonGroup(); for (Difficulty difficulty : Difficulty.values()) { - JRadioButtonMenuItem diffItem = new JRadioButtonMenuItem(difficulty.getName()); - diffItem.setSelected(server.getSaveProperties().getDifficulty() == difficulty); + JRadioButtonMenuItem diffItem = new JRadioButtonMenuItem(difficulty.getDisplayName().getString()); + diffItem.setSelected(server.getWorldData().getDifficulty() == difficulty); diffItem.addActionListener(e -> setDifficulty(difficulty)); difficultyGroup.add(diffItem); difficultyMenu.add(diffItem); @@ -77,9 +107,9 @@ private JMenu createServerMenu() { // Default Game Mode submenu JMenu gameModeMenu = new JMenu("Default Game Mode"); ButtonGroup gameModeGroup = new ButtonGroup(); - for (GameMode mode : GameMode.values()) { - JRadioButtonMenuItem modeItem = new JRadioButtonMenuItem(mode.getTranslatableName().getString()); - modeItem.setSelected(server.getSaveProperties().getGameMode() == mode); + for (GameType mode : GameType.values()) { + JRadioButtonMenuItem modeItem = new JRadioButtonMenuItem(mode.getName()); + modeItem.setSelected(server.getWorldData().getGameType() == mode); modeItem.addActionListener(e -> setDefaultGameMode(mode)); gameModeGroup.add(modeItem); gameModeMenu.add(modeItem); @@ -174,13 +204,13 @@ private void showWhitelistSettings() { dialog.setLocationRelativeTo(parentFrame); // Whitelist enabled checkbox - JCheckBox enabledCheckbox = new JCheckBox("Whitelist Enabled", server.getPlayerManager().isWhitelistEnabled()); + JCheckBox enabledCheckbox = new JCheckBox("Whitelist Enabled", server.getPlayerList().isUsingWhitelist()); JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); topPanel.add(enabledCheckbox); // Whitelist entries DefaultListModel listModel = new DefaultListModel<>(); - for (String player : server.getPlayerManager().getWhitelist().getNames()) { + for (String player : server.getPlayerList().getWhiteListNames()) { listModel.addElement(player); } JList whitelistList = new JList<>(listModel); @@ -195,7 +225,7 @@ private void showWhitelistSettings() { addButton.addActionListener(e -> { String player = JOptionPane.showInputDialog(dialog, "Enter player name:", "Add to Whitelist", JOptionPane.QUESTION_MESSAGE); if (player != null && !player.trim().isEmpty()) { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "whitelist add " + player); + runCommand("whitelist add " + player); listModel.addElement(player); } }); @@ -203,13 +233,12 @@ private void showWhitelistSettings() { removeButton.addActionListener(e -> { String selected = whitelistList.getSelectedValue(); if (selected != null) { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "whitelist remove " + selected); + runCommand("whitelist remove " + selected); listModel.removeElement(selected); } }); - enabledCheckbox.addActionListener(e -> server.getCommandManager().parseAndExecute(server.getCommandSource(), - enabledCheckbox.isSelected() ? "whitelist on" : "whitelist off")); + enabledCheckbox.addActionListener(e -> runCommand(enabledCheckbox.isSelected() ? "whitelist on" : "whitelist off")); closeButton.addActionListener(e -> dialog.dispose()); @@ -225,19 +254,19 @@ private void showWhitelistSettings() { private void setDifficulty(Difficulty difficulty) { server.setDifficulty(difficulty, true); - JOptionPane.showMessageDialog(parentFrame, "Difficulty set to " + difficulty.getName(), + JOptionPane.showMessageDialog(parentFrame, "Difficulty set to " + difficulty.getDisplayName().getString(), "Success", JOptionPane.INFORMATION_MESSAGE); } - private void setDefaultGameMode(GameMode mode) { - server.setDefaultGameMode(mode); - JOptionPane.showMessageDialog(parentFrame, "Default game mode set to " + mode.getTranslatableName(), + private void setDefaultGameMode(GameType mode) { + server.setDefaultGameType(mode); + JOptionPane.showMessageDialog(parentFrame, "Default game mode set to " + mode.getName(), "Success", JOptionPane.INFORMATION_MESSAGE); } private void saveAllWorlds() { try { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "save-all"); + runCommand("save-all"); JOptionPane.showMessageDialog(parentFrame, "All worlds saved successfully!", "Success", JOptionPane.INFORMATION_MESSAGE); } catch (Exception e) { @@ -269,7 +298,7 @@ private void backupServer() { new Thread(() -> { try { // Save all first - server.getCommandManager().parseAndExecute(server.getCommandSource(), "save-all flush"); + runCommand("save-all flush"); Thread.sleep(2000); // Wait for save to complete // Create zip @@ -317,7 +346,7 @@ private JMenu createWorldMenu() { // World list submenu JMenu worldListMenu = new JMenu("World List"); - for (ServerWorld world : server.getWorlds()) { + for (ServerLevel world : server.getAllLevels()) { JMenuItem worldItem = new JMenuItem(getWorldName(world)); worldItem.addActionListener(e -> showWorldInfo(world)); worldListMenu.add(worldItem); @@ -379,8 +408,8 @@ private JMenu createWorldMenu() { private void showGameruleSettings() { // Dynamically get all loaded worlds - java.util.List worlds = new java.util.ArrayList<>(); - for (ServerWorld world : server.getWorlds()) { + java.util.List worlds = new java.util.ArrayList<>(); + for (ServerLevel world : server.getAllLevels()) { worlds.add(world); } @@ -408,8 +437,8 @@ private void showGameruleSettings() { if (selectedWorldName == null) return; // Find the selected world - ServerWorld selectedWorld = null; - for (ServerWorld world : worlds) { + ServerLevel selectedWorld = null; + for (ServerLevel world : worlds) { if (getWorldName(world).equals(selectedWorldName)) { selectedWorld = world; break; @@ -421,7 +450,7 @@ private void showGameruleSettings() { showGameruleDialog(selectedWorld, selectedWorldName); } - private void showGameruleDialog(ServerWorld world, String worldName) { + private void showGameruleDialog(ServerLevel world, String worldName) { JDialog dialog = new JDialog(parentFrame, "Game Rules - " + worldName, true); dialog.setLayout(new BorderLayout(10, 10)); dialog.setSize(700, 600); @@ -430,89 +459,68 @@ private void showGameruleDialog(ServerWorld world, String worldName) { // Create tabbed pane for categories JTabbedPane tabbedPane = new JTabbedPane(); - // Maps to store components by category - Map categoryPanels = new java.util.HashMap<>(); - Map categoryConstraints = new java.util.HashMap<>(); - - // Initialize panels for each category - for (GameRuleCategory category : GameRuleCategory.CATEGORIES) { - JPanel panel = new JPanel(new GridBagLayout()); - GridBagConstraints gbc = new GridBagConstraints(); - gbc.gridx = 0; - gbc.gridy = 0; - gbc.anchor = GridBagConstraints.WEST; - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.insets = new Insets(5, 10, 5, 10); - gbc.weightx = 1.0; - - categoryPanels.put(category, panel); - categoryConstraints.put(category, gbc); - } + // Maps to store components by category, created on demand so that + // no category (including modded ones) is ever silently dropped + Map categoryPanels = new HashMap<>(); + Map categoryConstraints = new HashMap<>(); + Set usedCategories = new LinkedHashSet<>(); // Store all gamerule components for later retrieval - Map gameruleComponents = new java.util.HashMap<>(); + Map gameruleComponents = new HashMap<>(); // Dynamically discover and add all gamerules - world.getGameRules().accept(new GameRuleVisitor() { + world.getGameRules().visitGameRuleTypes(new GameRuleTypeVisitor() { @Override public void visitBoolean(GameRule rule) { - GameRuleCategory category = rule.getCategory(); - JPanel panel = categoryPanels.get(category); + GameRuleCategory category = rule.category(); + JPanel panel = getOrCreateCategoryPanel(categoryPanels, categoryConstraints, usedCategories, category); GridBagConstraints gbc = categoryConstraints.get(category); - if (panel != null && gbc != null) { - // Get current value from the world - boolean currentValue = world.getGameRules().getValue(rule); + // Get current value from the world + boolean currentValue = world.getGameRules().get(rule); - // Create checkbox - JCheckBox checkbox = new JCheckBox(formatGameruleName(I18n.translate(rule.getTranslationKey())), currentValue); - checkbox.setName(I18n.translate(rule.getTranslationKey())); - checkbox.setToolTipText("Game rule: " + I18n.translate(rule.getTranslationKey())); + // Create checkbox + JCheckBox checkbox = new JCheckBox(formatGameruleName(rule.id()), currentValue); + checkbox.setName(rule.id()); + checkbox.setToolTipText("Game rule: " + rule.id()); - panel.add(checkbox, gbc); - gbc.gridy++; + panel.add(checkbox, gbc); + gbc.gridy++; - gameruleComponents.put(I18n.translate(rule.getTranslationKey()), checkbox); - } + gameruleComponents.put(rule.id(), checkbox); } @Override - public void visitInt(GameRule rule) { - GameRuleCategory category = rule.getCategory(); - JPanel panel = categoryPanels.get(category); + public void visitInteger(GameRule rule) { + GameRuleCategory category = rule.category(); + JPanel panel = getOrCreateCategoryPanel(categoryPanels, categoryConstraints, usedCategories, category); GridBagConstraints gbc = categoryConstraints.get(category); - if (panel != null && gbc != null) { - // Get current value from the world - int currentValue = world.getGameRules().getValue(rule); - - // Create row panel - JPanel rowPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); - JLabel nameLabel = new JLabel(formatGameruleName(I18n.translate(rule.getTranslationKey())) + ":"); - nameLabel.setPreferredSize(new Dimension(300, 25)); + // Get current value from the world + int currentValue = world.getGameRules().get(rule); - // Dynamically extract min/max from the IntegerArgumentType - int min = 0; - int max = 1000000; + // Create row panel + JPanel rowPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + JLabel nameLabel = new JLabel(formatGameruleName(rule.id()) + ":"); + nameLabel.setPreferredSize(new Dimension(300, 25)); - JSpinner spinner = new JSpinner(new SpinnerNumberModel(currentValue, min, max, 1)); - spinner.setName(I18n.translate(rule.getTranslationKey())); - spinner.setPreferredSize(new Dimension(100, 25)); - spinner.setToolTipText("Game rule: " + I18n.translate(rule.getTranslationKey()) + " (Min: " + min + ", Max: " + max + ")"); + JSpinner spinner = new JSpinner(new SpinnerNumberModel(currentValue, Integer.MIN_VALUE, Integer.MAX_VALUE, 1)); + spinner.setName(rule.id()); + spinner.setPreferredSize(new Dimension(100, 25)); + spinner.setToolTipText("Game rule: " + rule.id()); - rowPanel.add(nameLabel); - rowPanel.add(spinner); + rowPanel.add(nameLabel); + rowPanel.add(spinner); - panel.add(rowPanel, gbc); - gbc.gridy++; + panel.add(rowPanel, gbc); + gbc.gridy++; - gameruleComponents.put(I18n.translate(rule.getTranslationKey()), spinner); - } + gameruleComponents.put(rule.id(), spinner); } }); - // Add all category panels to tabbed pane - for (GameRuleCategory category : GameRuleCategory.CATEGORIES) { + // Add all category panels to tabbed pane (only categories that actually contain rules) + for (GameRuleCategory category : usedCategories) { JPanel panel = categoryPanels.get(category); if (panel != null && panel.getComponentCount() > 0) { JScrollPane scrollPane = new JScrollPane(panel); @@ -557,6 +565,30 @@ public void visitInt(GameRule rule) { dialog.setVisible(true); } + private JPanel getOrCreateCategoryPanel(Map categoryPanels, + Map categoryConstraints, + Set usedCategories, + GameRuleCategory category) { + JPanel existing = categoryPanels.get(category); + if (existing != null) { + return existing; + } + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = 0; + gbc.anchor = GridBagConstraints.WEST; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(5, 10, 5, 10); + gbc.weightx = 1.0; + + categoryPanels.put(category, panel); + categoryConstraints.put(category, gbc); + usedCategories.add(category); + return panel; + } + private String formatGameruleName(String name) { // Convert camelCase to Title Case with spaces StringBuilder result = new StringBuilder(); @@ -582,7 +614,7 @@ private String formatGameruleName(String name) { } private String getCategoryDisplayName(GameRuleCategory category) { - return category.getText().getString(); + return category.label().getString(); } private void applyGamerules(Map components) { @@ -602,45 +634,38 @@ private void applyGamerules(Map components) { } } - private void resetGamerulesToDefaults(ServerWorld world) { - // Reset common gamerules to their defaults using GameRules class knowledge - world.getGameRules().accept(new GameRuleVisitor() { + private void resetGamerulesToDefaults(ServerLevel world) { + // Reset common gamerules to their defaults + world.getGameRules().visitGameRuleTypes(new GameRuleTypeVisitor() { @Override public void visitBoolean(GameRule rule) { - executeGamerule(I18n.translate(rule.getTranslationKey()), String.valueOf(rule.getDefaultValue())); + executeGamerule(rule.id(), String.valueOf(rule.defaultValue())); } @Override - public void visitInt(GameRule rule) { - executeGamerule(I18n.translate(rule.getTranslationKey()), String.valueOf(rule.getDefaultValue())); + public void visitInteger(GameRule rule) { + executeGamerule(rule.id(), String.valueOf(rule.defaultValue())); } }); } private void executeGamerule(String rule, String value) { - try { - server.getCommandManager().parseAndExecute( - server.getCommandSource(), - "gamerule " + rule + " " + value - ); - } catch (Exception e) { - System.err.println("Failed to set gamerule " + rule + ": " + e.getMessage()); - } + runCommand("gamerule " + rule + " " + value); } - private String getWorldName(ServerWorld world) { - String dimensionKey = world.getRegistryKey().getValue().toString(); + private String getWorldName(ServerLevel world) { + String dimensionKey = world.dimension().identifier().toString(); if (dimensionKey.contains("overworld")) return "The Overworld"; if (dimensionKey.contains("the_nether")) return "The Nether"; if (dimensionKey.contains("the_end")) return "The End"; return dimensionKey; } - private void showWorldInfo(ServerWorld world) { + private void showWorldInfo(ServerLevel world) { String info = String.format( """ World: %s - + Dimension: %s Loaded Chunks: %d Entities: %d @@ -648,19 +673,19 @@ private void showWorldInfo(ServerWorld world) { Weather: %s Difficulty: %s""", getWorldName(world), - world.getRegistryKey().getValue().toString(), - world.getChunkManager().getLoadedChunkCount(), - world.iterateEntities().spliterator().estimateSize(), - world.getTimeOfDay(), + world.dimension().identifier().toString(), + world.getChunkSource().getLoadedChunksCount(), + world.getAllEntities().spliterator().estimateSize(), + world.getLevelData().getGameTime(), world.isRaining() ? (world.isThundering() ? "Thunder" : "Rain") : "Clear", - world.getDifficulty().getName() + world.getLevelData().getDifficulty().getDisplayName().getString() ); JOptionPane.showMessageDialog(parentFrame, info, "World Information", JOptionPane.INFORMATION_MESSAGE); } private void showWorldBorderSettings() { - ServerWorld overworld = server.getOverworld(); + ServerLevel overworld = server.overworld(); if (overworld == null) return; JDialog dialog = new JDialog(parentFrame, "World Border Settings", true); @@ -693,10 +718,8 @@ private void showWorldBorderSettings() { double centerZ = Double.parseDouble(centerZField.getText()); double size = Double.parseDouble(sizeField.getText()); - server.getCommandManager().parseAndExecute(server.getCommandSource(), - String.format("worldborder center %f %f", centerX, centerZ)); - server.getCommandManager().parseAndExecute(server.getCommandSource(), - String.format("worldborder set %f", size)); + runCommand(String.format("worldborder center %f %f", centerX, centerZ)); + runCommand(String.format("worldborder set %f", size)); JOptionPane.showMessageDialog(dialog, "World border updated!", "Success", JOptionPane.INFORMATION_MESSAGE); dialog.dispose(); @@ -716,7 +739,7 @@ private void showWorldBorderSettings() { private void addTimeOption(JMenu menu, String name, long time) { JMenuItem item = new JMenuItem(name); item.addActionListener(e -> { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "time set " + time); + runCommand("time set " + time); }); menu.add(item); } @@ -726,7 +749,7 @@ private void setCustomTime() { if (input != null) { try { long time = Long.parseLong(input); - server.getCommandManager().parseAndExecute(server.getCommandSource(), "time set " + time); + runCommand("time set " + time); } catch (NumberFormatException e) { JOptionPane.showMessageDialog(parentFrame, "Invalid time value!", "Error", JOptionPane.ERROR_MESSAGE); } @@ -736,7 +759,7 @@ private void setCustomTime() { private void addWeatherOption(JMenu menu, String name, String weather) { JMenuItem item = new JMenuItem(name); item.addActionListener(e -> { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "weather " + weather); + runCommand("weather " + weather); }); menu.add(item); } @@ -751,8 +774,7 @@ private void setCustomWeather() { if (duration != null) { try { int seconds = Integer.parseInt(duration); - server.getCommandManager().parseAndExecute(server.getCommandSource(), - "weather " + weather.toLowerCase() + " " + seconds); + runCommand("weather " + weather.toLowerCase() + " " + seconds); } catch (NumberFormatException e) { JOptionPane.showMessageDialog(parentFrame, "Invalid duration!", "Error", JOptionPane.ERROR_MESSAGE); } @@ -766,13 +788,13 @@ private void reloadChunks() { "Confirm", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "reload"); + runCommand("reload"); JOptionPane.showMessageDialog(parentFrame, "Chunks reloaded!", "Success", JOptionPane.INFORMATION_MESSAGE); } } private void forceSave() { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "save-all flush"); + runCommand("save-all flush"); JOptionPane.showMessageDialog(parentFrame, "Force save completed!", "Success", JOptionPane.INFORMATION_MESSAGE); } @@ -795,7 +817,7 @@ private void backupWorld() { new Thread(() -> { try { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "save-all flush"); + runCommand("save-all flush"); Thread.sleep(2000); // Backup specific world folder @@ -821,10 +843,11 @@ private void backupWorld() { } private void showGeneralWorldInfo() { - ServerWorld overworld = server.getOverworld(); + ServerLevel overworld = server.overworld(); if (overworld == null) return; - WorldProperties properties = overworld.getLevelProperties(); + LevelData properties = overworld.getLevelData(); + LevelData.RespawnData spawn = properties.getRespawnData(); String info = String.format( "World Information\n\n" + @@ -834,10 +857,10 @@ private void showGeneralWorldInfo() { "Hardcore: %s\n" + "Allow Commands: %s", overworld.getSeed(), - properties.getSpawnPoint().getPos().getX(), properties.getSpawnPoint().getPos().getY(), properties.getSpawnPoint().getPos().getZ(), - properties.getDifficulty().getName(), + spawn.pos().getX(), spawn.pos().getY(), spawn.pos().getZ(), + properties.getDifficulty().getDisplayName().getString(), properties.isHardcore() ? "Yes" : "No", - overworld.getGameRules().getValue(GameRules.COMMAND_BLOCKS_WORK) ? "Yes" : "No" + overworld.getGameRules().get(GameRules.COMMAND_BLOCKS_WORK) ? "Yes" : "No" ); JTextArea textArea = new JTextArea(info); @@ -858,6 +881,85 @@ private void showGeneralWorldInfo() { JOptionPane.showMessageDialog(parentFrame, panel, "World Information", JOptionPane.INFORMATION_MESSAGE); } + // ==================== NETWORK MENU ==================== + private JMenu createNetworkMenu() { + JMenu networkMenu = new JMenu("Network"); + networkMenu.setMnemonic('N'); + + JMenuItem refreshItem = new JMenuItem("Refresh Network Information"); + refreshItem.addActionListener(event -> showNetworkInformation()); + networkMenu.add(refreshItem); + + networkMenu.addSeparator(); + JMenuItem infoItem = new JMenuItem("Show Network Information..."); + infoItem.addActionListener(event -> showNetworkInformation()); + networkMenu.add(infoItem); + return networkMenu; + } + + private void showNetworkInformation() { + int javaPort = server.getServerPort(); + JDialog dialog = new JDialog(parentFrame, "Network", true); + dialog.setLayout(new BorderLayout(8, 8)); + dialog.setSize(520, 260); + dialog.setLocationRelativeTo(parentFrame); + JTextArea textArea = new JTextArea("Reading network configuration..."); + textArea.setEditable(false); + textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + dialog.add(new JScrollPane(textArea), BorderLayout.CENTER); + ThemeManager.getInstance().applyTo(dialog); + + // Start the reader before showing the modal dialog; setVisible(true) blocks + // this EDT callback until the user closes the dialog. + new Thread(() -> { + Integer bedrockPort = readGeyserBedrockPort(); + String bedrock = bedrockPort == null + ? "Not detected (Geyser may be unavailable or configured elsewhere)" + : Integer.toString(bedrockPort); + String info = "Network Information\n\n" + + "Java Edition port: " + javaPort + "\n" + + "Bedrock Edition port: " + bedrock + "\n\n" + + "Java address: 0.0.0.0:" + javaPort + "\n" + + "Bedrock address: 0.0.0.0:" + bedrock; + SwingUtilities.invokeLater(() -> textArea.setText(info)); + }, "DedicatedPower network configuration reader").start(); + dialog.setVisible(true); + } + + private Integer readGeyserBedrockPort() { + Path[] candidates = { + Path.of("config", "Geyser-Fabric", "config.yml"), + Path.of("config", "Geyser-Fabric", "config.yaml"), + Path.of("config", "geyser-fabric", "config.yml") + }; + for (Path candidate : candidates) { + if (!Files.isRegularFile(candidate)) { + continue; + } + try { + List lines = Files.readAllLines(candidate); + boolean inBedrock = false; + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("bedrock:")) { + inBedrock = true; + continue; + } + if (inBedrock && !line.isBlank() && !Character.isWhitespace(line.charAt(0))) { + inBedrock = false; + } + if (inBedrock && (trimmed.startsWith("port:") || trimmed.startsWith("port :"))) { + String value = trimmed.substring(trimmed.indexOf(':') + 1).trim(); + return Integer.parseInt(value); + } + } + } catch (IOException | NumberFormatException ignored) { + // Continue with the next known Geyser config location. + } + } + return null; + } + // ==================== PERFORMANCE MENU ==================== private JMenu createPerformanceMenu() { JMenu perfMenu = new JMenu("Performance"); @@ -908,7 +1010,7 @@ private void addClearEntityOption(JMenu menu, String name, String type) { default -> "kill @e[type=" + type + "]"; }; - server.getCommandManager().parseAndExecute(server.getCommandSource(), command); + runCommand(command); JOptionPane.showMessageDialog(parentFrame, "Entities cleared!", "Success", JOptionPane.INFORMATION_MESSAGE); } }); @@ -926,7 +1028,7 @@ private void optimizeChunks() { new Thread(() -> { try { // Force save and reload - server.getCommandManager().parseAndExecute(server.getCommandSource(), "save-all flush"); + runCommand("save-all flush"); Thread.sleep(2000); System.gc(); @@ -1011,13 +1113,15 @@ private void generatePerformanceReport() { int totalEntities = 0; int totalChunks = 0; - for (ServerWorld world : server.getWorlds()) { - for (Object ignored : world.iterateEntities()) { + for (ServerLevel world : server.getAllLevels()) { + for (Object ignored : world.getAllEntities()) { totalEntities++; } - totalChunks += world.getChunkManager().getLoadedChunkCount(); + totalChunks += world.getChunkSource().getLoadedChunksCount(); } + double avgTickMs = server.getAverageTickTimeNanos() / 1_000_000.0; + StringBuilder report = new StringBuilder(); report.append("=== PERFORMANCE REPORT ===\n"); report.append("Generated: ").append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())).append("\n\n"); @@ -1026,9 +1130,9 @@ private void generatePerformanceReport() { report.append(String.format("Max: %d MB\n", maxMemory / 1024 / 1024)); report.append(String.format("Usage: %.1f%%\n\n", (usedMemory * 100.0) / maxMemory)); report.append("=== SERVER ===\n"); - report.append(String.format("TPS: %.2f\n", Math.min(20.0, 1000.0 / server.getAverageTickTime()))); - report.append(String.format("Average Tick: %.2f ms\n", server.getAverageTickTime())); - report.append(String.format("Players: %d\n", server.getCurrentPlayerCount())); + report.append(String.format("TPS: %.2f\n", Math.min(20.0, 1000.0 / avgTickMs))); + report.append(String.format("Average Tick: %.2f ms\n", avgTickMs)); + report.append(String.format("Players: %d\n", server.getPlayerCount())); report.append(String.format("Entities: %d\n", totalEntities)); report.append(String.format("Chunks: %d\n\n", totalChunks)); report.append("=== SYSTEM ===\n"); @@ -1072,6 +1176,20 @@ private JMenu createToolsMenu() { JMenu toolsMenu = new JMenu("Tools"); toolsMenu.setMnemonic('T'); + JMenuItem appearanceItem = new JMenuItem("Appearance..."); + appearanceItem.addActionListener(e -> showAppearanceSettings()); + toolsMenu.add(appearanceItem); + + JMenuItem motdItem = new JMenuItem("MOTD Editor..."); + motdItem.addActionListener(e -> showMotdEditor()); + toolsMenu.add(motdItem); + + JMenuItem exportModsItem = new JMenuItem("Export Mod List..."); + exportModsItem.addActionListener(e -> exportModList()); + toolsMenu.add(exportModsItem); + + toolsMenu.addSeparator(); + JMenuItem commandPaletteItem = new JMenuItem("Command Palette..."); commandPaletteItem.setAccelerator(KeyStroke.getKeyStroke("ctrl shift P")); commandPaletteItem.addActionListener(e -> showCommandPalette()); @@ -1104,6 +1222,649 @@ private JMenu createToolsMenu() { return toolsMenu; } + private void showAppearanceSettings() { + ThemeManager themeManager = ThemeManager.getInstance(); + JDialog dialog = new JDialog(parentFrame, "Appearance", true); + dialog.setLayout(new BorderLayout(10, 10)); + dialog.setSize(520, 460); + dialog.setLocationRelativeTo(parentFrame); + + JPanel content = new JPanel(new BorderLayout(10, 10)); + content.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + JPanel themePanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + themePanel.setBorder(BorderFactory.createTitledBorder("Theme")); + JRadioButton lightButton = new JRadioButton("Light", !themeManager.isDark()); + JRadioButton darkButton = new JRadioButton("Dark", themeManager.isDark()); + ButtonGroup themeGroup = new ButtonGroup(); + themeGroup.add(lightButton); + themeGroup.add(darkButton); + themePanel.add(lightButton); + themePanel.add(darkButton); + content.add(themePanel, BorderLayout.NORTH); + + JPanel palettePanel = new JPanel(new GridLayout(0, 2, 8, 8)); + palettePanel.setBorder(BorderFactory.createTitledBorder("Log colors")); + Map colorButtons = new EnumMap<>(EnhancedLogPanel.LogLevel.class); + for (EnhancedLogPanel.LogLevel level : EnhancedLogPanel.LogLevel.values()) { + JLabel label = new JLabel(level.name()); + JButton colorButton = new JButton("Choose color"); + colorButton.putClientProperty("dedicatedpower.palettePreview", Boolean.TRUE); + colorButton.setOpaque(true); + colorButton.setBorderPainted(true); + colorButton.setBackground(themeManager.getLogColor(level)); + colorButton.putClientProperty("dedicatedpower.paletteColor", themeManager.getLogColor(level)); + colorButton.setToolTipText("Current color: " + toHex(themeManager.getLogColor(level))); + colorButton.addActionListener(event -> { + Color selected = JColorChooser.showDialog(dialog, level + " log color", + colorButton.getBackground()); + if (selected != null) { + colorButton.setBackground(selected); + colorButton.putClientProperty("dedicatedpower.paletteColor", selected); + colorButton.setToolTipText("Current color: " + toHex(selected)); + } + }); + colorButtons.put(level, colorButton); + palettePanel.add(label); + palettePanel.add(colorButton); + } + content.add(palettePanel, BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton resetButton = new JButton("Reset palette"); + resetButton.addActionListener(event -> { + Map defaultsMap = ThemeManager.defaultLogColors(); + for (Map.Entry entry : colorButtons.entrySet()) { + Color color = defaultsMap.get(entry.getKey()); + entry.getValue().setBackground(color); + entry.getValue().putClientProperty("dedicatedpower.paletteColor", color); + entry.getValue().setToolTipText("Current color: " + toHex(color)); + } + }); + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(event -> dialog.dispose()); + JButton applyButton = new JButton("Apply"); + applyButton.addActionListener(event -> { + Map colors = new EnumMap<>(EnhancedLogPanel.LogLevel.class); + for (Map.Entry entry : colorButtons.entrySet()) { + colors.put(entry.getKey(), entry.getValue().getBackground()); + } + themeManager.applySettings(darkButton.isSelected() ? ThemeManager.Theme.DARK : ThemeManager.Theme.LIGHT, colors); + themeManager.applyTo(parentFrame); + dialog.dispose(); + }); + buttons.add(resetButton); + buttons.add(cancelButton); + buttons.add(applyButton); + + dialog.add(content, BorderLayout.CENTER); + dialog.add(buttons, BorderLayout.SOUTH); + themeManager.applyTo(dialog); + dialog.setVisible(true); + } + + private void showMotdEditor() { + ThemeManager themeManager = ThemeManager.getInstance(); + JDialog dialog = new JDialog(parentFrame, "MOTD Editor", true); + dialog.setLayout(new BorderLayout(12, 12)); + dialog.setSize(960, 650); + dialog.setMinimumSize(new Dimension(780, 540)); + dialog.setLocationRelativeTo(parentFrame); + + MotdHistoryStore.State history = MotdHistoryStore.load(); + Color input = themeManager.getInputBackground(); + Color muted = themeManager.getMutedForeground(); + + JPanel root = new JPanel(new BorderLayout(12, 12)); + root.setBorder(BorderFactory.createEmptyBorder(14, 14, 12, 14)); + + JPanel heading = new JPanel(new BorderLayout(8, 2)); + JLabel title = new JLabel("MOTD Editor"); + title.setFont(title.getFont().deriveFont(Font.BOLD, 19f)); + JLabel subtitle = new JLabel("Create the message players see in the server list (up to 2 lines)"); + subtitle.setForeground(muted); + JPanel headingText = new JPanel(); + headingText.setLayout(new BoxLayout(headingText, BoxLayout.Y_AXIS)); + headingText.add(title); + headingText.add(subtitle); + heading.add(headingText, BorderLayout.WEST); + JLabel support = new JLabel("Minecraft legacy formatting supported"); + support.setForeground(muted); + heading.add(support, BorderLayout.EAST); + root.add(heading, BorderLayout.NORTH); + + JTextArea editor = new JTextArea(normalizeMotdInput(server.getMotd()), 5, 42); + editor.setLineWrap(true); + editor.setWrapStyleWord(true); + editor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + editor.setMargin(new Insets(10, 10, 10, 10)); + editor.setTabSize(2); + // Minecraft shows at most 2 MOTD lines regardless of line length, so the + // document filter trims anything beyond that for typing, pasting, loading + // history entries, and resetting. + if (editor.getDocument() instanceof AbstractDocument abstractDocument) { + abstractDocument.setDocumentFilter(new DocumentFilter() { + @Override + public void insertString(FilterBypass bypass, int offset, String text, AttributeSet attrs) + throws BadLocationException { + applyMotdEdit(bypass, offset, 0, text, attrs); + } + + @Override + public void replace(FilterBypass bypass, int offset, int length, String text, AttributeSet attrs) + throws BadLocationException { + applyMotdEdit(bypass, offset, length, text, attrs); + } + }); + } + // Normalize a MOTD that already exceeded the limit (e.g. from server.properties). + editor.setText(limitMotdLines(editor.getText(), MAX_MOTD_LINES)); + JScrollPane editorScroll = new JScrollPane(editor); + editorScroll.setBorder(BorderFactory.createLineBorder(themeManager.getBorderColor())); + + // Formatting controls: a wrapping strip so every button is always fully + // visible with no horizontal scrollbar. WrapLayout reflows the buttons + // onto additional rows when the editor column is narrow. + JPanel toolbar = new JPanel(new WrapLayout(FlowLayout.LEFT, 6, 4)); + toolbar.setBorder(BorderFactory.createEmptyBorder(3, 4, 5, 4)); + addMotdCodeButton(toolbar, "Black", "0", editor); + addMotdCodeButton(toolbar, "Dark Blue", "1", editor); + addMotdCodeButton(toolbar, "Dark Green", "2", editor); + addMotdCodeButton(toolbar, "Dark Aqua", "3", editor); + addMotdCodeButton(toolbar, "Dark Red", "4", editor); + addMotdCodeButton(toolbar, "Dark Purple", "5", editor); + addMotdCodeButton(toolbar, "Gold", "6", editor); + addMotdCodeButton(toolbar, "Gray", "7", editor); + addMotdCodeButton(toolbar, "Dark Gray", "8", editor); + addMotdCodeButton(toolbar, "Blue", "9", editor); + addMotdCodeButton(toolbar, "Green", "a", editor); + addMotdCodeButton(toolbar, "Aqua", "b", editor); + addMotdCodeButton(toolbar, "Red", "c", editor); + addMotdCodeButton(toolbar, "Light Purple", "d", editor); + addMotdCodeButton(toolbar, "Yellow", "e", editor); + addMotdCodeButton(toolbar, "White", "f", editor); + toolbar.add(Box.createHorizontalStrut(10)); + addMotdCodeButton(toolbar, "Bold", "l", editor); + addMotdCodeButton(toolbar, "Underline", "n", editor); + addMotdCodeButton(toolbar, "Italic", "o", editor); + addMotdCodeButton(toolbar, "Strikethrough", "m", editor); + addMotdCodeButton(toolbar, "Obfuscated", "k", editor); + addMotdCodeButton(toolbar, "Reset", "r", editor); + + JLabel counter = new JLabel(); + counter.setForeground(muted); + JLabel status = new JLabel("Ready to apply"); + status.setForeground(muted); + JPanel editorStatus = new JPanel(new BorderLayout()); + editorStatus.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); + editorStatus.add(counter, BorderLayout.WEST); + editorStatus.add(status, BorderLayout.EAST); + + JPanel editorPanel = new JPanel(new BorderLayout(4, 4)); + editorPanel.setBorder(BorderFactory.createTitledBorder("Message")); + editorPanel.add(toolbar, BorderLayout.NORTH); + editorPanel.add(editorScroll, BorderLayout.CENTER); + editorPanel.add(editorStatus, BorderLayout.SOUTH); + + JTextPane preview = new JTextPane(); + preview.setContentType("text/html"); + preview.setEditable(false); + preview.setFocusable(false); + preview.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + preview.setBackground(input); + + JLabel previewTitle = new JLabel("DedicatedPower Server"); + previewTitle.setFont(previewTitle.getFont().deriveFont(Font.BOLD, 14f)); + JLabel previewState = new JLabel("Online • 20 TPS"); + previewState.setForeground(new Color(76, 175, 80)); + JPanel previewHeader = new JPanel(new BorderLayout()); + previewHeader.setBorder(BorderFactory.createEmptyBorder(10, 12, 4, 12)); + previewHeader.add(previewTitle, BorderLayout.WEST); + previewHeader.add(previewState, BorderLayout.EAST); + JPanel previewCard = new JPanel(new BorderLayout()); + previewCard.setBorder(BorderFactory.createLineBorder(themeManager.getBorderColor())); + previewCard.add(previewHeader, BorderLayout.NORTH); + previewCard.add(preview, BorderLayout.CENTER); + + JList historyList = new JList<>(new DefaultListModel<>()); + refreshMotdHistoryModel(historyList, history); + historyList.setVisible(history.isEnabled()); + historyList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + historyList.setCellRenderer((list, value, index, selected, focused) -> { + // The HTML body background would otherwise cover the selection + // highlight, so the selected row is painted with the selection color. + Color cellBackground = selected ? themeManager.getSelectionBackground() : themeManager.getInputBackground(); + JLabel cell = new JLabel(motdToHtml(value, cellBackground)); + cell.setOpaque(true); + cell.setBorder(BorderFactory.createEmptyBorder(7, 8, 7, 8)); + cell.setBackground(cellBackground); + cell.setForeground(themeManager.getForeground()); + return cell; + }); + historyList.setToolTipText("Select a saved MOTD to load it into the editor"); + + JPanel historyPanel = new JPanel(new BorderLayout(6, 6)); + historyPanel.setBorder(BorderFactory.createTitledBorder("Saved MOTDs")); + JScrollPane historyScroll = new JScrollPane(historyList); + historyPanel.add(historyScroll, BorderLayout.CENTER); + JPanel historyButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + JCheckBox historyEnabled = new JCheckBox("Remember history", history.isEnabled()); + JButton deleteButton = new JButton("Delete"); + JButton clearButton = new JButton("Clear"); + historyEnabled.addActionListener(event -> { + history.setEnabled(historyEnabled.isSelected()); + historyList.setVisible(history.isEnabled()); + historyPanel.revalidate(); + historyPanel.repaint(); + }); + deleteButton.addActionListener(event -> { + String selected = historyList.getSelectedValue(); + if (selected != null) { + history.getEntries().remove(selected); + refreshMotdHistoryModel(historyList, history); + } + }); + clearButton.addActionListener(event -> { + if (!history.getEntries().isEmpty() + && JOptionPane.showConfirmDialog(dialog, "Clear all saved MOTDs?", "Clear history", + JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION) { + history.getEntries().clear(); + refreshMotdHistoryModel(historyList, history); + } + }); + historyButtons.add(historyEnabled); + historyButtons.add(deleteButton); + historyButtons.add(clearButton); + historyPanel.add(historyButtons, BorderLayout.SOUTH); + + JPanel leftColumn = new JPanel(new BorderLayout(8, 8)); + leftColumn.add(editorPanel, BorderLayout.CENTER); + leftColumn.add(previewCard, BorderLayout.SOUTH); + + JPanel rightColumn = new JPanel(new BorderLayout(8, 8)); + rightColumn.add(historyPanel, BorderLayout.CENTER); + rightColumn.setPreferredSize(new Dimension(280, 0)); + + JPanel workspace = new JPanel(new GridLayout(1, 2, 12, 0)); + workspace.add(leftColumn); + workspace.add(rightColumn); + root.add(workspace, BorderLayout.CENTER); + + Runnable updateStatus = () -> { + String text = editor.getText(); + int length = text.length(); + int lines = text.isEmpty() ? 0 : 1 + countNewlines(text); + counter.setText(lines + "/" + MAX_MOTD_LINES + " lines · " + length + " characters"); + counter.setToolTipText("Minecraft shows up to " + MAX_MOTD_LINES + " lines in the server list"); + status.setText(lines > MAX_MOTD_LINES ? "Too many lines" : "Ready to apply"); + }; + Runnable updatePreview = () -> preview.setText(motdToHtml(editor.getText())); + editor.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() { + private void changed() { + updatePreview.run(); + updateStatus.run(); + } + public void insertUpdate(javax.swing.event.DocumentEvent event) { changed(); } + public void removeUpdate(javax.swing.event.DocumentEvent event) { changed(); } + public void changedUpdate(javax.swing.event.DocumentEvent event) { changed(); } + }); + updatePreview.run(); + updateStatus.run(); + + historyList.addListSelectionListener(event -> { + if (!event.getValueIsAdjusting() && historyList.getSelectedValue() != null) { + editor.setText(normalizeMotdInput(historyList.getSelectedValue())); + editor.requestFocusInWindow(); + } + }); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + JButton resetButton = new JButton("Reset"); + resetButton.setToolTipText("Restore the MOTD currently used by the server"); + resetButton.addActionListener(event -> editor.setText(normalizeMotdInput(server.getMotd()))); + JButton copyButton = new JButton("Copy MOTD"); + copyButton.addActionListener(event -> { + StringSelection selection = new StringSelection(normalizeMotdInput(editor.getText())); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + status.setText("Copied to clipboard"); + }); + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(event -> dialog.dispose()); + JButton applyButton = new JButton("Save & Apply"); + applyButton.addActionListener(event -> { + String motd = normalizeMotdInput(editor.getText()).trim(); + if (motd.isEmpty()) { + JOptionPane.showMessageDialog(dialog, "MOTD cannot be empty.", "Invalid MOTD", JOptionPane.ERROR_MESSAGE); + return; + } + server.execute(() -> server.setMotd(motd)); + if (history.isEnabled()) { + history.add(motd); + } + new Thread(() -> { + updateServerPropertiesMotd(motd); + MotdHistoryStore.save(history); + }, "DedicatedPower MOTD persistence").start(); + dialog.dispose(); + }); + buttons.add(resetButton); + buttons.add(copyButton); + buttons.add(cancelButton); + buttons.add(applyButton); + + dialog.add(root, BorderLayout.CENTER); + dialog.add(buttons, BorderLayout.SOUTH); + ThemeManager.getInstance().applyTo(dialog); + + // Explicit styling pass so every control follows the current theme + // regardless of Look & Feel defaults (caret, selection, secondary text, + // toolbar strip, scrollbars, and checkboxes included). + editor.setBackground(themeManager.getInputBackground()); + editor.setForeground(themeManager.getForeground()); + editor.setCaretColor(themeManager.getForeground()); + editor.setSelectionColor(themeManager.getSelectionBackground()); + editor.setSelectedTextColor(themeManager.getForeground()); + editorScroll.getViewport().setBackground(themeManager.getInputBackground()); + + preview.setBackground(themeManager.getInputBackground()); + historyList.setBackground(themeManager.getInputBackground()); + historyList.setForeground(themeManager.getForeground()); + historyScroll.getViewport().setBackground(themeManager.getInputBackground()); + + subtitle.setForeground(themeManager.getMutedForeground()); + support.setForeground(themeManager.getMutedForeground()); + counter.setForeground(themeManager.getMutedForeground()); + status.setForeground(themeManager.getMutedForeground()); + + toolbar.setBackground(themeManager.getSurfaceBackground()); + + historyEnabled.setBackground(themeManager.getPanelBackground()); + historyEnabled.setForeground(themeManager.getForeground()); + dialog.setVisible(true); + } + + private void addMotdCodeButton(JComponent container, String label, String code, JTextArea editor) { + JButton button = new JButton(label); + button.setFont(button.getFont().deriveFont(Font.PLAIN, 11f)); + button.setMargin(new Insets(3, 8, 3, 8)); + button.setToolTipText("Insert Minecraft formatting code §" + code); + button.addActionListener(event -> { + editor.insert("§" + code, editor.getCaretPosition()); + editor.requestFocusInWindow(); + }); + container.add(button); + } + + /** + * Converts the common escaped MOTD notation into the canonical Minecraft + * representation. This accepts both the Java/properties form (\\u00A7 and + * \\n) and the slash-prefixed form often copied from launcher or web examples + * (/u00A7 and /n), while preserving already decoded § formatting codes. + */ + private static String normalizeMotdInput(String text) { + return MotdTextUtils.normalize(text); + } + + private static String normalizeMotdInput(String text, boolean allowSlashNewlineAtStart) { + return MotdTextUtils.normalize(text, allowSlashNewlineAtStart); + } + + private static int countNewlines(String text) { + int count = 0; + for (int index = 0; index < text.length(); index++) { + if (text.charAt(index) == '\n') { + count++; + } + } + return count; + } + + /** + * Truncates a MOTD so it keeps at most {@code maxLines} lines; the length of + * each individual line is irrelevant to Minecraft's server list rendering. + */ + private static String limitMotdLines(String text, int maxLines) { + return MotdTextUtils.limitLines(text, maxLines); + } + + /** + * Applies a document edit while keeping the total at two lines. The inserted + * text is trimmed so it can never add more lines than the remaining budget; + * an edit that would create a line beyond the limit (for example pressing + * Enter on the second line) is rejected entirely so the caret stays in place. + */ + private void applyMotdEdit(DocumentFilter.FilterBypass bypass, int offset, int length, + String text, AttributeSet attrs) throws BadLocationException { + int documentLength = bypass.getDocument().getLength(); + String current = bypass.getDocument().getText(0, documentLength); + String prefix = current.substring(0, Math.min(offset, documentLength)); + String suffix = current.substring(Math.min(offset + length, documentLength)); + String normalizedPrefix = normalizeMotdInput(prefix); + boolean allowSlashNewlineAtStart = normalizedPrefix.isEmpty() || normalizedPrefix.endsWith("§r"); + String normalizedText = normalizeMotdInput(text, allowSlashNewlineAtStart); + int existingNewlines = countNewlines(prefix) + countNewlines(suffix); + String limitedText = limitMotdLines(normalizedText, MAX_MOTD_LINES - existingNewlines); + if (limitedText.isEmpty() && !normalizedText.isEmpty()) { + // Reject edits that would exceed the line limit (e.g. Enter on line 2). + return; + } + bypass.replace(offset, length, limitedText, attrs); + } + + + private void refreshMotdHistoryModel(JList list, MotdHistoryStore.State history) { + DefaultListModel model = new DefaultListModel<>(); + for (String entry : history.getEntries()) { + model.addElement(entry); + } + list.setModel(model); + } + + private void updateServerPropertiesMotd(String motd) { + Path path = Path.of("server.properties"); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + } catch (IOException error) { + if (Files.exists(path)) { + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(parentFrame, + "MOTD applied in memory, but the existing server.properties could not be read: " + error.getMessage(), + "MOTD Warning", JOptionPane.WARNING_MESSAGE)); + return; + } + // A missing properties file can be created normally. + } + properties.setProperty("motd", motd); + try (OutputStream output = Files.newOutputStream(path, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + properties.store(output, "Minecraft server properties - Modified via DedicatedPower"); + } catch (IOException error) { + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(parentFrame, + "MOTD applied in memory, but server.properties could not be saved: " + error.getMessage(), + "MOTD Warning", JOptionPane.WARNING_MESSAGE)); + } + } + + private String motdToHtml(String motd) { + ThemeManager themeManager = ThemeManager.getInstance(); + return motdToHtml(motd, themeManager.getInputBackground()); + } + + private String motdToHtml(String motd, Color background) { + String[] colors = {"000000", "0000AA", "00AA00", "00AAAA", "AA0000", "AA00AA", "FFAA00", "AAAAAA", + "555555", "5555FF", "55FF55", "55FFFF", "FF5555", "FF55FF", "FFFF55", "FFFFFF"}; + ThemeManager themeManager = ThemeManager.getInstance(); + StringBuilder html = new StringBuilder(""); + boolean colorOpen = false; + boolean bold = false; + boolean italic = false; + boolean underline = false; + boolean strike = false; + boolean obfuscated = false; + + motd = normalizeMotdInput(motd); + for (int index = 0; index < motd.length(); index++) { + char character = motd.charAt(index); + if (character == '§' && index + 1 < motd.length()) { + char code = Character.toLowerCase(motd.charAt(++index)); + int colorIndex = "0123456789abcdef".indexOf(code); + if (colorIndex >= 0) { + if (obfuscated) { html.append(""); } + closeMotdFormatting(html, bold, italic, underline, strike, colorOpen); + html.append(""); + colorOpen = true; + bold = italic = underline = strike = obfuscated = false; + } else if (code == 'l' || code == 'o' || code == 'n' || code == 'm') { + if (code == 'l' && !bold) { html.append(""); bold = true; } + if (code == 'o' && !italic) { html.append(""); italic = true; } + if (code == 'n' && !underline) { html.append(""); underline = true; } + if (code == 'm' && !strike) { html.append(""); strike = true; } + } else if (code == 'k') { + // Minecraft randomizes glyphs; a browser preview cannot reproduce + // that renderer, so indicate the obfuscated segment visually. + if (!obfuscated) { + html.append(""); + obfuscated = true; + } + } else if (code == 'r') { + if (obfuscated) { html.append(""); } + closeMotdFormatting(html, bold, italic, underline, strike, colorOpen); + colorOpen = false; + bold = italic = underline = strike = obfuscated = false; + } + continue; + } + if (character == '\n') { + html.append("
"); + } else if (character == '&') { + html.append("&"); + } else if (character == '<') { + html.append("<"); + } else if (character == '>') { + html.append(">"); + } else { + html.append(character); + } + } + if (obfuscated) { html.append("
"); } + closeMotdFormatting(html, bold, italic, underline, strike, colorOpen); + return html.append("").toString(); + } + + private void closeMotdFormatting(StringBuilder html, boolean bold, boolean italic, + boolean underline, boolean strike, boolean colorOpen) { + if (strike) html.append(""); + if (underline) html.append(""); + if (italic) html.append(""); + if (bold) html.append(""); + if (colorOpen) html.append(""); + } + + private String toHex(Color color) { + return String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue()); + } + + private void exportModList() { + List mods = new ArrayList<>(FabricLoader.getInstance().getAllMods()); + mods.sort(Comparator.comparing(mod -> mod.getMetadata().getName().toLowerCase(Locale.ROOT))); + + JDialog dialog = new JDialog(parentFrame, "Export Mod List", true); + dialog.setLayout(new BorderLayout(10, 10)); + dialog.setSize(460, 230); + dialog.setLocationRelativeTo(parentFrame); + + JPanel options = new JPanel(new GridLayout(0, 1, 8, 8)); + options.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + options.add(new JLabel(mods.size() + " installed mods detected")); + JRadioButton textButton = new JRadioButton("Plain text (.txt)", true); + JRadioButton markdownButton = new JRadioButton("Markdown table (.md)"); + ButtonGroup formatGroup = new ButtonGroup(); + formatGroup.add(textButton); + formatGroup.add(markdownButton); + options.add(textButton); + options.add(markdownButton); + dialog.add(options, BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(event -> dialog.dispose()); + JButton exportButton = new JButton("Export..."); + exportButton.addActionListener(event -> { + boolean markdown = markdownButton.isSelected(); + dialog.dispose(); + saveModList(markdown, mods); + }); + buttons.add(cancelButton); + buttons.add(exportButton); + dialog.add(buttons, BorderLayout.SOUTH); + + ThemeManager.getInstance().applyTo(dialog); + dialog.setVisible(true); + } + + private void saveModList(boolean markdown, List mods) { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Save Mod List"); + chooser.setSelectedFile(new File(markdown ? "mod-list.md" : "mod-list.txt")); + if (chooser.showSaveDialog(parentFrame) != JFileChooser.APPROVE_OPTION) { + return; + } + File target = chooser.getSelectedFile(); + String content = markdown ? buildModListMarkdown(mods) : buildModListText(mods); + try (FileWriter writer = new FileWriter(target)) { + writer.write(content); + JOptionPane.showMessageDialog(parentFrame, + "Mod list saved to:\n" + target.getAbsolutePath(), + "Export Complete", JOptionPane.INFORMATION_MESSAGE); + } catch (IOException error) { + JOptionPane.showMessageDialog(parentFrame, + "Failed to save mod list: " + error.getMessage(), + "Export Error", JOptionPane.ERROR_MESSAGE); + } + } + + private String buildModListText(List mods) { + StringBuilder output = new StringBuilder(); + output.append("Minecraft Mod List\n"); + output.append("Generated: ").append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())).append('\n'); + output.append("Total mods: ").append(mods.size()).append("\n\n"); + for (ModContainer mod : mods) { + output.append("- ").append(mod.getMetadata().getName()) + .append(" (").append(mod.getMetadata().getId()).append(')') + .append(" v").append(mod.getMetadata().getVersion().getFriendlyString()).append('\n'); + String description = mod.getMetadata().getDescription(); + if (description != null && !description.isBlank()) { + output.append(" ").append(description.replace('\n', ' ').trim()).append('\n'); + } + } + return output.toString(); + } + + private String buildModListMarkdown(List mods) { + StringBuilder output = new StringBuilder(); + output.append("# Minecraft Mod List\n\n"); + output.append("Generated: ").append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())) + .append(" — ").append(mods.size()).append(" mods\n\n"); + output.append("| Name | ID | Version | Description |\n"); + output.append("|---|---|---|---|\n"); + for (ModContainer mod : mods) { + output.append("| ").append(escapeMarkdownCell(mod.getMetadata().getName())) + .append(" | ").append(escapeMarkdownCell(mod.getMetadata().getId())) + .append(" | ").append(escapeMarkdownCell(mod.getMetadata().getVersion().getFriendlyString())) + .append(" | ").append(escapeMarkdownCell(mod.getMetadata().getDescription())) + .append(" |\n"); + } + return output.toString(); + } + + private String escapeMarkdownCell(String value) { + if (value == null) { + return ""; + } + return value.replace("|", "\\|").replace('\n', ' ').trim(); + } + private void showCommandPalette() { JDialog dialog = new JDialog(parentFrame, "Command Palette", false); dialog.setLayout(new BorderLayout()); @@ -1139,10 +1900,10 @@ private void showCommandPalette() { searchField.addActionListener(e -> { String selected = commandList.getSelectedValue(); if (selected != null) { - server.getCommandManager().parseAndExecute(server.getCommandSource(), selected); + runCommand(selected); dialog.dispose(); } else if (!searchField.getText().isEmpty()) { - server.getCommandManager().parseAndExecute(server.getCommandSource(), searchField.getText()); + runCommand(searchField.getText()); dialog.dispose(); } }); @@ -1227,7 +1988,7 @@ private void showDatapackManager() { JButton closeButton = new JButton("Close"); reloadButton.addActionListener(e -> { - server.getCommandManager().parseAndExecute(server.getCommandSource(), "reload"); + runCommand("reload"); JOptionPane.showMessageDialog(dialog, "Datapacks reloaded!", "Success", JOptionPane.INFORMATION_MESSAGE); }); @@ -1304,6 +2065,16 @@ private JMenu createHelpMenu() { JMenu helpMenu = new JMenu("Help"); helpMenu.setMnemonic('H'); + JMenuItem githubItem = new JMenuItem("GitHub Repository"); + githubItem.addActionListener(e -> openURL("https://github.com/SuperSirvu/DedicatedPower")); + helpMenu.add(githubItem); + + JMenuItem modrinthItem = new JMenuItem("Modrinth Page"); + modrinthItem.addActionListener(e -> openURL("https://modrinth.com/mod/server-os")); + helpMenu.add(modrinthItem); + + helpMenu.addSeparator(); + JMenuItem wikiItem = new JMenuItem("Minecraft Wiki"); wikiItem.addActionListener(e -> openURL("https://minecraft.wiki")); helpMenu.add(wikiItem); @@ -1345,12 +2116,12 @@ private void openURL(String url) { private void showCommandReference() { String commands = """ === ESSENTIAL COMMANDS === - + /help - Show all commands /list - Show online players /say - Broadcast message /tell - Private message - + === PLAYER MANAGEMENT === /kick [reason] - Kick player /ban [reason] - Ban player @@ -1358,26 +2129,27 @@ private void showCommandReference() { /op - Give operator status /deop - Remove operator status /whitelist add/remove - Manage whitelist - + === WORLD === /time set