diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8e61e05 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI +on: + push: + branches: ['**'] + tags-ignore: ['**'] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + verify: + name: Rust, web and container + runs-on: linux-build + steps: + - uses: builtin:checkout + - name: Verify Linux runner + run: | + set -eu + test "$(uname -s)" = Linux + for tool in cargo rustc node npm docker; do command -v "$tool" >/dev/null || { echo "Missing $tool on linux-build runner"; exit 1; }; done + test "$(node -p 'process.versions.node.split(".")[0]')" -ge 22 + rustup component list --installed | grep -q rustfmt + rustup component list --installed | grep -q clippy + pkg-config --exists webkit2gtk-4.1 ayatana-appindicator3-0.1 librsvg-2.0 || { echo 'Missing Tauri GTK/WebKit development packages'; exit 1; } + docker info >/dev/null || { echo 'Docker daemon unavailable'; exit 1; } + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + panel/web/package-lock.json + launcher/package-lock.json + - name: Check Rust + run: | + mkdir -p launcher/dist + echo '' > launcher/dist/index.html + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked + - name: Check panel and launcher UI + run: | + for app in panel/web launcher; do + (cd "$app" && npm ci --no-audit --no-fund && npm run check && npm run build) + done + - name: Build panel container + run: docker build -t scopenet-panel:ci . + + launcher-windows: + name: Windows launcher + runs-on: windows-build + steps: + - uses: builtin:checkout + - name: Verify Windows runner + shell: pwsh + run: | + if (-not $IsWindows) { throw 'windows-build must be a Windows host runner' } + foreach ($tool in @('cargo', 'rustc', 'node', 'npm')) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Missing $tool" } + } + if ([int](node -p 'process.versions.node.split(".")[0]') -lt 22) { throw 'Node 22+ required' } + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: launcher/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: launcher + - run: npm run build + working-directory: launcher + - run: cargo clippy -p scopenet-launcher --locked -- -D warnings + - run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked + + engine-online: + name: Real Minecraft installs (manual) + if: ${{ gitea.event_name == 'workflow_dispatch' }} + runs-on: linux-build + continue-on-error: true + steps: + - uses: builtin:checkout + - name: Verify runner + run: | + test "$(uname -s)" = Linux + command -v cargo + - uses: Swatinem/rust-cache@v2 + - run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture diff --git a/.gitea/workflows/integrations.yml b/.gitea/workflows/integrations.yml new file mode 100644 index 0000000..a4d16bb --- /dev/null +++ b/.gitea/workflows/integrations.yml @@ -0,0 +1,88 @@ +name: Server integrations +on: + push: + branches: ['**'] + paths: ['integrations/**', '.gitea/workflows/integrations.yml'] + pull_request: + paths: ['integrations/**', '.gitea/workflows/integrations.yml'] + workflow_dispatch: + workflow_call: + inputs: + version: + type: string + default: '0.5.0' + +permissions: + contents: read + +jobs: + paper: + runs-on: linux-build + steps: + - uses: builtin:checkout + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Verify integration runner + run: | + set -eu + test "$(uname -s)" = Linux + command -v java + java -version 2>&1 | grep -q '21\.' || { echo 'Paper needs Java 21'; exit 1; } + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.10.2' + - run: gradle -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.5.0' }}" + - uses: actions/upload-artifact@v4 + with: + name: server-paper + path: integrations/paper/build/libs/scopenet-paper-*.jar + if-no-files-found: error + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: integration-test-reports + path: integrations/common/build/reports/tests/ + + mods: + name: Fabric and Forge / ${{ matrix.mc.version }} + runs-on: linux-build + strategy: + fail-fast: false + matrix: + mc: + - { version: '1.20.1', java: '21', gradle: '8.10.2' } + - { version: '1.21.1', java: '21', gradle: '8.10.2' } + - { version: '26.1.2', java: '25', gradle: '9.6.0' } + - { version: '26.2', java: '25', gradle: '9.6.0' } + - { version: '26.3', java: '25', gradle: '9.6.0' } + steps: + - uses: builtin:checkout + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.mc.java }} + - name: Verify integration runner + run: | + set -eu + test "$(uname -s)" = Linux + command -v java + java -version 2>&1 | grep -q '${{ matrix.mc.java }}\.' || { echo 'Wrong Java version for this matrix entry'; exit 1; } + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: ${{ matrix.mc.gradle }} + - name: Build both loaders + run: | + for loader in fabric forge; do + gradle -p integrations -Ploader="$loader" -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.5.0' }}" :"$loader":build + done + - name: Audit Fabric startup hooks + run: gradle -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.5.0' }}" -PverifyMixins=true :fabric:runServer + - uses: actions/upload-artifact@v4 + with: + name: server-mods-${{ matrix.mc.version }} + path: | + integrations/fabric/build/libs/scopenet-fabric-${{ matrix.mc.version }}-*.jar + integrations/forge/build/libs/scopenet-forge-${{ matrix.mc.version }}-*.jar + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.gitea/workflows/release.yml similarity index 59% rename from .github/workflows/release.yml rename to .gitea/workflows/release.yml index 9e32d5f..c4324dc 100644 --- a/.github/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,6 +1,6 @@ name: Release -# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`) +# Publish by pushing a tag to the gitea remote (`git push gitea v0.5.0`) # or from the Actions tab → Release → Run workflow (enter a version). # # Optional repository variables (Settings → Secrets and variables → Actions → Variables): @@ -18,7 +18,8 @@ on: required: true permissions: - contents: write + contents: read + releases: write packages: write env: @@ -26,31 +27,44 @@ env: jobs: version: - runs-on: ubuntu-24.04 + runs-on: linux-build outputs: version: ${{ steps.v.outputs.version }} tag: ${{ steps.v.outputs.tag }} steps: + - name: Verify Linux runner + run: | + test "$(uname -s)" = Linux + command -v bash - id: v shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}"; else v="${GITHUB_REF_NAME}"; fi + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then v="$INPUT_VERSION"; else v="$GITHUB_REF_NAME"; fi v="${v#v}" if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi - echo "version=$v" >> "$GITHUB_OUTPUT" - echo "tag=v$v" >> "$GITHUB_OUTPUT" + echo "version=$v" >> "$GITEA_OUTPUT" + echo "tag=v$v" >> "$GITEA_OUTPUT" launcher: name: Windows installer needs: version - runs-on: windows-latest + runs-on: windows-build steps: - - uses: actions/checkout@v5 + - uses: builtin:checkout + - name: Verify Windows runner + shell: pwsh + run: | + if (-not $IsWindows) { throw 'windows-build must be a Windows host runner' } + foreach ($tool in @('cargo', 'node', 'npm')) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Missing $tool" } + } - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: key: release - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v4 with: node-version: 22 cache: npm @@ -74,7 +88,7 @@ jobs: env: SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }} SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }} - SCOPENET_REPO: ${{ github.repository }} + SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }} LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }} run: | if [ -n "$LAUNCHER_NAME" ]; then @@ -98,21 +112,27 @@ jobs: if-no-files-found: error panel-image: - name: Panel image (GHCR) + name: Panel image (Gitea registry) needs: version - runs-on: ubuntu-24.04 + runs-on: linux-build steps: - - uses: actions/checkout@v5 - - name: Image name (GHCR needs lowercase) - run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV" + - uses: builtin:checkout + - name: Verify container runner + run: | + test "$(uname -s)" = Linux + command -v docker + docker info >/dev/null + docker buildx version >/dev/null + - name: Image name + run: echo "IMAGE=${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITEA_ENV" - name: Stamp version run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: gitea.jadonsandbox.com + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: @@ -128,20 +148,25 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max server-integrations: needs: version - uses: ./.github/workflows/integrations.yml + uses: ./.gitea/workflows/integrations.yml with: version: ${{ needs.version.outputs.version }} release: - name: GitHub release + name: Gitea release needs: [version, launcher, panel-image, server-integrations] - runs-on: ubuntu-24.04 + runs-on: linux-build steps: + - name: Verify release runner + run: | + test "$(uname -s)" = Linux + command -v curl + command -v jq - uses: actions/download-artifact@v4 with: name: windows-installer @@ -153,7 +178,7 @@ jobs: path: dist - name: Release notes run: | - IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" + IMAGE="${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" cat > notes.md </dev/null; then continue; fi + curl --fail-with-body -sS -H "Authorization: token $TOKEN" -F "attachment=@$file" "$api/$id/assets?name=$(basename "$file")" >/dev/null + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 11e5475..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: CI - -on: - push: - branches: ['**'] - tags-ignore: ['**'] - pull_request: - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - -jobs: - rust: - name: Rust (fmt, clippy, tests) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Install Tauri system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev - - name: Placeholder launcher frontend (tauri::generate_context needs it) - run: mkdir -p launcher/dist && echo '' > launcher/dist/index.html - - run: cargo fmt --all --check - - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - run: cargo test --workspace --locked - - web: - name: Web UIs (type-check + build) - runs-on: ubuntu-24.04 - strategy: - matrix: - app: [panel/web, launcher] - defaults: - run: - working-directory: ${{ matrix.app }} - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: npm - cache-dependency-path: ${{ matrix.app }}/package-lock.json - - run: npm ci --no-audit --no-fund - - run: npm run check - - run: npm run build - - launcher-windows: - name: Launcher (Windows build check) - runs-on: windows-latest - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: npm - cache-dependency-path: launcher/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: launcher - - run: npm run build - working-directory: launcher - - run: cargo clippy -p scopenet-launcher --locked -- -D warnings - - run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked - - engine-online: - name: Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) - runs-on: ubuntu-24.04 - # Talks to third-party servers; a hiccup there shouldn't block merges. - continue-on-error: true - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Install games (vanilla, legacy, Fabric, Quilt, Forge, NeoForge) - run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture - - docker: - name: Docker image builds - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: false - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml deleted file mode 100644 index b0f505a..0000000 --- a/.github/workflows/integrations.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Server integrations - -on: - push: - branches: ['**'] - paths: - - 'integrations/**' - - '.github/workflows/integrations.yml' - - pull_request: - paths: - - 'integrations/**' - - '.github/workflows/integrations.yml' - - workflow_dispatch: - - workflow_call: - inputs: - version: - type: string - default: '0.4.0' - -permissions: - contents: read - -jobs: - paper: - name: Paper and protocol tests - runs-on: ubuntu-24.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Setup Java 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Install Gradle 8.10.2 - run: | - curl -s "https://get.sdkman.io" | bash - source "$HOME/.sdkman/bin/sdkman-init.sh" - sdk install gradle 8.10.2 - gradle --version - - - name: Build Paper plugin and run tests - run: | - gradle -p integrations \ - :common:test \ - :paper:build \ - -PreleaseVersion="${{ inputs.version || '0.4.0' }}" - - - name: Upload Paper JAR - uses: actions/upload-artifact@v4 - with: - name: server-paper - path: integrations/paper/build/libs/scopenet-paper-*.jar - if-no-files-found: error - - - name: Upload integration test reports - if: failure() - uses: actions/upload-artifact@v4 - with: - name: integration-test-reports - path: integrations/common/build/reports/tests/ - - mods: - name: ${{ matrix.loader }} / ${{ matrix.mc.version }} - runs-on: ubuntu-24.04 - - strategy: - fail-fast: false - matrix: - loader: - - fabric - - forge - - mc: - - version: '1.20.1' - java: '21' - gradle: '8.10.2' - - - version: '1.21.1' - java: '21' - gradle: '8.10.2' - - - version: '26.1.2' - java: '25' - gradle: '9.6.0' - - - version: '26.2' - java: '25' - gradle: '9.6.0' - - - version: '26.3' - java: '25' - gradle: '9.6.0' - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ matrix.mc.java }} - - - name: Install Gradle - run: | - curl -s "https://get.sdkman.io" | bash - source "$HOME/.sdkman/bin/sdkman-init.sh" - sdk install gradle ${{ matrix.mc.gradle }} - gradle --version - - - name: Build ${{ matrix.loader }} ${{ matrix.mc.version }} - run: | - gradle -p integrations \ - -Ploader=${{ matrix.loader }} \ - -PmcVersion=${{ matrix.mc.version }} \ - -PreleaseVersion="${{ inputs.version || '0.4.0' }}" \ - :${{ matrix.loader }}:build - - - name: Audit Fabric hooks during server bootstrap - if: matrix.loader == 'fabric' - run: | - gradle -p integrations \ - -Ploader=fabric \ - -PmcVersion=${{ matrix.mc.version }} \ - -PreleaseVersion="${{ inputs.version || '0.4.0' }}" \ - -PverifyMixins=true \ - :fabric:runServer - - - name: Upload Mod JAR - uses: actions/upload-artifact@v4 - with: - name: server-${{ matrix.loader }}-${{ matrix.mc.version }} - path: integrations/${{ matrix.loader }}/build/libs/scopenet-${{ matrix.loader }}-${{ matrix.mc.version }}-*.jar - if-no-files-found: error \ No newline at end of file diff --git a/README.md b/README.md index 8db3306..f18f825 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes - **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1. - **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen. - **Settings** — memory with a recommendation for your PC, resolution, fullscreen, what happens on launch, Java path, GC presets (G1 / ZGC), JVM args, **Minecraft keybind editor** (applied to every instance), custom accent colour, glass/animation toggles, UI scale, storage manager, per-instance overrides. -- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from GitHub Releases, single-instance window, `Ctrl+Enter` to play. +- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from Gitea Releases, single-instance window, `Ctrl+Enter` to play. - **Branding from the panel** — name, logo, colours, font, corner radius, glass, image/video background, news feed, social links, custom CSS. No rebuild needed. ### Admin panel features @@ -52,8 +52,8 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes ### 1. Run the panel ```bash -git clone https://github.com/scopeddlol/SCOPENET-MC.git -cd SCOPENET-MC +git clone https://gitea.jadonsandbox.com/blade/scopenet-mc.git +cd scopenet-mc cp .env.example .env # edit .env → set ADMIN_PASSWORD and PUBLIC_URL for your domain docker compose config --quiet @@ -70,15 +70,19 @@ Open `http://your-server:8080`, sign in as `admin`, then: ### 2. Build your branded launcher -1. In GitHub → **Settings → Secrets and variables → Actions → Variables**, add: +1. In Gitea → **Repository Settings → Actions → Variables**, add: - `PANEL_URL` = `https://panel.example.com` (players connect automatically) - `LOCK_PANEL` = `1` (optional: hide the "change server" option) - `LAUNCHER_NAME` = `MyServer Launcher` (optional) 2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`. -The workflow builds the **Windows installer**, pushes the **panel image to GHCR** (`ghcr.io//scopenet-mc-panel`, amd64 + arm64) and publishes a **GitHub release**. Installed launchers pick up new releases automatically. +The workflow builds the **Windows installer**, pushes the **panel image to Gitea registry** (`gitea.jadonsandbox.com/blade/scopenet-mc-panel`, amd64 + arm64) and publishes a **Gitea release**. Installed launchers pick up new releases automatically. -> The GHCR package is private by default the first time — make it public under your GitHub profile → Packages if you want `docker pull` without logging in. +> The Gitea registry package may be private at first — make it public under the repository owner’s Packages settings if you want `docker pull` without logging in. + +### Gitea runner setup + +Register a Linux host runner with the `linux-build` label and a Windows host runner with `windows-build`. The CI workflows check the host OS and required tools before building. The Linux host needs Rust with `rustfmt` and `clippy`, Node 22+, Java 21 and 25 for the integration matrix, Docker with Buildx, `pkg-config`, the Tauri WebKit/GTK development libraries, `curl`, and `jq`. The Windows host needs Rust, Node 22+, PowerShell and Git Bash. Enable Actions for the repository and allow its job token to write releases and packages. See [Gitea's runner labels](https://docs.gitea.com/runner/labels/) and [token permissions](https://docs.gitea.com/usage/actions/token-permissions/). ## Repository layout @@ -92,7 +96,7 @@ launcher/ Desktop launcher UI (Svelte 5) launcher/src-tauri Desktop launcher shell (Tauri 2) Dockerfile Panel image (static binary on scratch) docker-compose.yml Panel deployment -.github/workflows CI + release (installer, GitHub release, GHCR image) +.gitea/workflows CI + release (installer, Gitea release, Gitea registry image) ``` ## Documentation diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 1765e98..94db621 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -80,7 +80,7 @@ Everything on this page is pushed to launchers when they start or refresh — no ## Releasing the launcher -See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer. +See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks Gitea Releases on start and offers the new installer. **Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`. diff --git a/docs/map.md b/docs/map.md index 224d446..b0fc4d9 100644 --- a/docs/map.md +++ b/docs/map.md @@ -17,15 +17,15 @@ It is on by default for every server. To change it: 1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch. 2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read. -The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority. +The first tiles should appear after the server starts; the time depends on region size and storage speed. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority. ## In the launcher -Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles. +Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players. Open **Online** to choose individual players and whether their skin heads appear, or to jump to a player; use the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles. ## In the admin panel -**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update). +**Servers → your server** shows tile counts, the game server’s current rendering stage, region files found, queued and uploaded tiles, recent errors, and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update). ## On your public landing page @@ -43,6 +43,6 @@ Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the ## Troubleshooting -- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing. +- **"Drawing the world…" never ends.** Run `/map` on Paper or `/scopenet map` on Fabric to see the server-side stage. Check **Servers → your server** for the same progress and the last error. If no region files are found, check the world folder; if uploads fail, check the panel URL and token. The server log says `SCOPENET Map is on` when it starts drawing. - **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18). - **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour. diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 3551d11..b15fa61 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -175,6 +175,11 @@ public final class Integration implements AutoCloseable { if (current != null) current.requestScan(); } + public String mapStatus() { + net.scopenet.worldmap.WorldMapSync current = map; + return current == null ? "Map is not started. Check map.enabled in the server config and restart." : current.status(); + } + public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) { return client.checkChunk(dimension, chunkX, chunkZ, uuid); } diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java index a5a4169..deafac0 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java @@ -32,6 +32,8 @@ public final class TileStore { private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); } + public boolean exists(Key k) { return Files.isRegularFile(file(k)); } + /** The tile's pixels, or null if it was never rendered. */ public synchronized int[] load(Key k) { int[] cached = memory.get(k); diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java index 825aa07..929f64f 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java @@ -68,6 +68,14 @@ public final class WorldMapSync implements AutoCloseable { private long overlaySentAt; private String lastPlayersSent = ""; private long playersSentAt; + private volatile String stage = "starting"; + private volatile String lastError = ""; + private volatile String lastRegion = ""; + private volatile long regionsRendered; + private volatile long tilesUploaded; + private volatile long lastUploadAt; + private volatile int regionFiles; + private volatile int queuedTiles; public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer log) { this.client = client; @@ -90,6 +98,15 @@ public final class WorldMapSync implements AutoCloseable { players = List.copyOf(next); } + /** Safe to read on the game thread for /map and /scopenet map. */ + public String status() { + return "Map " + (enabled ? stage : "waiting for panel/map setting") + "; world=" + worldRoot + + "; region files=" + regionFiles + "; rendered=" + regionsRendered + + "; queued tiles=" + queuedTiles + "; uploaded=" + tilesUploaded + + (lastRegion.isEmpty() ? "" : "; last region=" + lastRegion) + + (lastError.isEmpty() ? "" : "; error=" + lastError); + } + /** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */ public void setOverlay(java.util.function.Supplier source) { overlay = source; @@ -126,12 +143,17 @@ public final class WorldMapSync implements AutoCloseable { } catch (PanelClient.HttpFailure e) { if (e.status == 403 || e.status == 404) enabled = false; else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")"); + lastError = "panel HTTP " + e.status + ": " + e.getMessage(); + stage = "upload failed"; pause = 30_000; } catch (Exception e) { log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + lastError = e.getClass().getSimpleName() + ": " + e.getMessage(); + stage = "failed"; pause = 60_000; } if (stateDirty) saveState(); + queuedTiles = state.dirty.size(); try { synchronized (wake) { if (!wakeRequested) wake.wait(pause); @@ -163,8 +185,12 @@ public final class WorldMapSync implements AutoCloseable { } if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel."); enabled = on; + stage = on ? "scanning regions" : "disabled on panel"; + if (on) lastError = ""; } catch (PanelClient.HttpFailure e) { enabled = false; // an older panel without the map + stage = "panel configuration failed"; + lastError = "panel HTTP " + e.status + ": " + e.getMessage(); } return enabled; } @@ -172,6 +198,7 @@ public final class WorldMapSync implements AutoCloseable { /** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */ private boolean cycle() throws IOException, InterruptedException { if (!Files.isDirectory(worldRoot)) { + stage = "world folder missing"; if (!warnedMissing) { warnedMissing = true; log.accept("SCOPENET Map: world folder " + worldRoot + " not found"); @@ -181,12 +208,15 @@ public final class WorldMapSync implements AutoCloseable { long now = System.currentTimeMillis(); record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {} List stale = new ArrayList<>(); + int filesFound = 0; + int completeFiles = 0; List online = players; for (Dim dim : DIMS) { Path dir = regionDir(dim); if (dir == null) continue; try (DirectoryStream stream = Files.newDirectoryStream(dir, "r.*.*.mca")) { for (Path file : stream) { + filesFound++; String[] parts = file.getFileName().toString().split("\\."); int rx, rz; try { @@ -195,13 +225,14 @@ public final class WorldMapSync implements AutoCloseable { } catch (RuntimeException e) { continue; } + if (Files.size(file) <= 8192) continue; + completeFiles++; long mtime = Files.getLastModifiedTime(file).toMillis(); String key = dim.dir() + "/" + rx + "." + rz; Long done = state.regions.get(key); if (done != null && mtime <= done) continue; Long last = renderedAt.get(key); - if (done != null && last != null && now - last < RERENDER_GAP_MS) continue; - if (Files.size(file) < 8192) continue; + if (last != null && now - last < RERENDER_GAP_MS) continue; double distance = Double.MAX_VALUE; for (Player p : online) { if (!dim.id().equals(p.dimension())) continue; @@ -212,27 +243,41 @@ public final class WorldMapSync implements AutoCloseable { } } } + regionFiles = filesFound; + if (filesFound == 0) stage = "no region files found"; + else if (completeFiles == 0) stage = "region files have no saved chunks yet"; boolean worked = false; if (!stale.isEmpty()) { stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key)); Stale next = stale.get(0); - renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz()); - worked = true; + worked = renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz()); } if (flushUploads(stale.size() <= 1)) worked = true; + if (filesFound > 0 && stale.isEmpty() && state.dirty.isEmpty() && state.regions.size() >= filesFound) stage = "caught up"; return worked; } - private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException { + private boolean renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException { + stage = "rendering " + key; + lastRegion = key; int[][] tiles; try { tiles = RegionRenderer.render(file, dim.kind()); } catch (IOException | RuntimeException e) { // A region being written right now; try again after the gap. renderedAt.put(key, System.currentTimeMillis()); - return; + lastError = "render " + key + ": " + e.getMessage(); + return false; } renderedAt.put(key, System.currentTimeMillis()); + if (tiles == null) { + // A region may still be generating or use a format we cannot read. Never mark it complete. + lastError = "No readable full chunks in " + key + "; will retry"; + stage = "waiting for readable chunks"; + return false; + } + lastError = ""; + regionsRendered++; List changed = new ArrayList<>(); if (tiles != null) { for (int i = 0; i < 4; i++) { @@ -261,6 +306,7 @@ public final class WorldMapSync implements AutoCloseable { } state.regions.put(key, mtime); stateDirty = true; + return true; } /** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */ @@ -297,6 +343,9 @@ public final class WorldMapSync implements AutoCloseable { } if (inBatch.isEmpty()) continue; client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray()); + tilesUploaded += inBatch.size(); + lastUploadAt = System.currentTimeMillis(); + stage = "uploading tiles"; for (TileStore.Key k : inBatch) state.dirty.remove(k.toString()); stateDirty = true; sentAny = true; @@ -344,12 +393,34 @@ public final class WorldMapSync implements AutoCloseable { lastOverlaySent = ov; overlaySentAt = now; } + if (now - diagnosticsSentAt >= 10_000) { + JsonObject diagnostic = new JsonObject(); + diagnostic.addProperty("stage", stage); + diagnostic.addProperty("error", lastError); + diagnostic.addProperty("world_root", worldRoot.toString()); + diagnostic.addProperty("region_files", regionFiles); + diagnostic.addProperty("regions_rendered", regionsRendered); + diagnostic.addProperty("queued_tiles", queuedTiles); + diagnostic.addProperty("tiles_uploaded", tilesUploaded); + diagnostic.addProperty("last_region", lastRegion); + diagnostic.addProperty("last_upload_ms", lastUploadAt); + try { + client.post("map/diagnostics", diagnostic); + diagnosticsSentAt = now; + } catch (PanelClient.HttpFailure e) { + // Diagnostics are optional while an older panel is being upgraded. + diagnosticsSentAt = now; + if (e.status != 404) lastError = "diagnostics HTTP " + e.status + ": " + e.getMessage(); + } + } } catch (PanelClient.HttpFailure e) { if (e.status == 403 || e.status == 404) enabled = false; - } catch (Exception ignored) { - // the next tick retries + lastError = "live upload HTTP " + e.status + ": " + e.getMessage(); + } catch (Exception e) { + lastError = "live upload: " + e.getMessage(); } } + private long diagnosticsSentAt; // ---- world layout ---- @@ -378,7 +449,23 @@ public final class WorldMapSync implements AutoCloseable { Path f = cacheDir.resolve("state.json"); if (Files.isRegularFile(f)) { State s = gson.fromJson(Files.readString(f), State.class); - if (s != null && s.regions != null && s.dirty != null) state = s; + if (s != null && s.regions != null && s.dirty != null) { + state = s; + // Older builds could mark an unreadable region complete without producing a tile. + // Clear those entries on upgrade so a live server can recover without an admin reset. + state.regions.keySet().removeIf(key -> { + String[] parts = key.split("[/ .]"); + if (parts.length != 3) return true; + try { + int rx = Integer.parseInt(parts[1]), rz = Integer.parseInt(parts[2]); + for (int i = 0; i < 4; i++) { + if (store.exists(new TileStore.Key(parts[0], 0, rx * 2 + (i & 1), rz * 2 + (i >> 1)))) return false; + } + } catch (NumberFormatException e) { return true; } + return true; + }); + stateDirty = true; + } } } catch (IOException | RuntimeException e) { state = new State(); diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java index f0c86ca..e2be092 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java @@ -19,7 +19,7 @@ final class ScopenetCommand implements CoreCommand { @Override public String permission() { return "scopenet.command.scopenet"; } @Override public List complete(CorePlayer player, String[] args) { - return args.length <= 1 ? List.of("help", "status", "reload") : List.of(); + return args.length <= 1 ? List.of("help", "status", "map", "reload") : List.of(); } @Override public void run(CorePlayer p, String[] args) { @@ -33,6 +33,11 @@ final class ScopenetCommand implements CoreCommand { if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; } features.sendStatus(p); } + case "map" -> { + if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to view map status."); return; } + net.scopenet.integration.Integration integration = net.scopenet.minecraft.Bridge.integration(); + p.send(Format.GOLD + "SCOPENET Map: " + Format.WHITE + (integration == null ? "Integration unavailable" : integration.mapStatus())); + } case "reload" -> { if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; } features.reload(p); @@ -51,5 +56,6 @@ final class ScopenetCommand implements CoreCommand { p.send(Format.YELLOW + " /" + c.name() + aliases); } p.send(Format.GRAY + "Open the launcher's Command guide for what each one does."); + p.send(Format.YELLOW + " /scopenet map" + Format.GRAY + " - Map rendering and upload progress"); } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 94b025c..933a733 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -87,6 +87,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { // Register /scopenet admin & help command ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat); bindCommand("scopenet", scopenetCmd); + bindCommand("map", scopenetCmd); // Register Essentials commands & GUI { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java index 800d03a..a9dcc2a 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java @@ -22,6 +22,10 @@ public final class ScopenetCommandHandler implements CommandExecutor { @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (command.getName().equalsIgnoreCase("map")) { + sendMap(sender); + return true; + } if (args.length == 0 || args[0].equalsIgnoreCase("help")) { if (!sender.hasPermission("scopenet.command.scopenet.help")) { sender.sendMessage(ChatColor.RED + "You do not have permission to view SCOPENET help."); @@ -34,6 +38,7 @@ public final class ScopenetCommandHandler implements CommandExecutor { String sub = args[0].toLowerCase(); switch (sub) { case "status" -> sendStatus(sender); + case "map" -> sendMap(sender); case "reload" -> handleReload(sender); default -> { sender.sendMessage(ChatColor.RED + "Unknown command. Use " + ChatColor.YELLOW + "/scopenet help"); @@ -92,6 +97,7 @@ public final class ScopenetCommandHandler implements CommandExecutor { if (sender.hasPermission("scopenet.admin")) { sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]"); sender.sendMessage(ChatColor.YELLOW + " /scopenet status" + ChatColor.GRAY + " - Integration health and module statuses"); + sender.sendMessage(ChatColor.YELLOW + " /map" + ChatColor.GRAY + " - Map rendering and upload progress"); sender.sendMessage(ChatColor.YELLOW + " /scopenet reload" + ChatColor.GRAY + " - Reload config and feature flags"); } } @@ -137,6 +143,14 @@ public final class ScopenetCommandHandler implements CommandExecutor { } } + private void sendMap(CommandSender sender) { + if (!sender.hasPermission("scopenet.command.scopenet.status")) { + sender.sendMessage(ChatColor.RED + "You do not have permission to view map status."); + return; + } + sender.sendMessage(ChatColor.GOLD + "SCOPENET Map: " + ChatColor.WHITE + integration.mapStatus()); + } + private String formatFlag(boolean enabled) { return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED"; } diff --git a/integrations/paper/src/main/resources/plugin.yml b/integrations/paper/src/main/resources/plugin.yml index dda1abb..24e364d 100644 --- a/integrations/paper/src/main/resources/plugin.yml +++ b/integrations/paper/src/main/resources/plugin.yml @@ -11,6 +11,9 @@ commands: permission: scopenet.command.scopenet description: SCOPENET server commands, status, and help aliases: [sn] + map: + permission: scopenet.command.scopenet.status + description: Show live map rendering and upload progress spawn: permission: scopenet.command.spawn description: Teleport to the server spawn point diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index 6800827..396034b 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -6,17 +6,17 @@ use scopenet_shared::LauncherManifest; use std::path::PathBuf; use std::sync::{Mutex, RwLock}; -/// Values baked in at build time by CI (see `.github/workflows/release.yml`). +/// Values baked in at build time by CI (see `.gitea/workflows/release.yml`). pub mod build { /// Default panel URL for this build. pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") { Some(u) if !u.is_empty() => Some(u), _ => Some("https://scopenetmcpanel.scopedd.lol"), }; - /// `owner/repo` used to check GitHub releases for updates. + /// Full Gitea repository URL used to check releases for updates. pub const REPO: Option<&str> = match option_env!("SCOPENET_REPO") { Some(r) if !r.is_empty() => Some(r), - _ => Some("scopeddlol/SCOPENET-MC"), + _ => Some("https://gitea.jadonsandbox.com/blade/scopenet-mc"), }; /// When set to "1", players can't change the panel URL. pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL"); diff --git a/launcher/src-tauri/src/updater.rs b/launcher/src-tauri/src/updater.rs index fe80dc5..74eddb3 100644 --- a/launcher/src-tauri/src/updater.rs +++ b/launcher/src-tauri/src/updater.rs @@ -1,4 +1,4 @@ -//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download +//! Self-update via Gitea Releases: find a newer `*-setup.exe`, download //! it and hand over to the installer. use crate::state::build; @@ -42,14 +42,12 @@ pub fn is_newer(candidate: &str, current: &str) -> bool { pub async fn check(http: &reqwest::Client) -> Result> { let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) }; - let release: Release = http - .get(format!("https://api.github.com/repos/{repo}/releases/latest")) - .header("Accept", "application/vnd.github+json") - .send() - .await? - .error_for_status()? - .json() - .await?; + let repo = reqwest::Url::parse(repo).context("invalid release repository URL")?; + if repo.scheme() != "https" { + anyhow::bail!("release repository must use HTTPS"); + } + let api = format!("{}/api/v1/repos{}/releases/latest", repo.origin().ascii_serialization(), repo.path()); + let release: Release = http.get(api).header("Accept", "application/json").send().await?.error_for_status()?.json().await?; if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) { return Ok(None); } @@ -68,7 +66,9 @@ pub async fn check(http: &reqwest::Client) -> Result> { /// Download the installer and start it. The caller exits the app afterwards. pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> { - if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") { + let expected = reqwest::Url::parse(build::REPO.unwrap_or_default()).context("invalid release repository URL")?; + let actual = reqwest::Url::parse(url).context("invalid update URL")?; + if actual.scheme() != "https" || actual.origin() != expected.origin() { anyhow::bail!("refusing to download an update from an unexpected host"); } let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe"); diff --git a/launcher/src/app.css b/launcher/src/app.css index 25379bc..1edfb98 100644 --- a/launcher/src/app.css +++ b/launcher/src/app.css @@ -41,13 +41,14 @@ html, body { user-select: none; -webkit-user-select: none; /* Long names and unbroken strings wrap inside their box instead of spilling out of it. */ - overflow-wrap: anywhere; + overflow-wrap: normal; + line-height: 1.5; } #app { height: 100%; } /* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */ -*, *::before, *::after { min-width: 0; } +.row > *, .col > *, .glass > * { min-width: 0; } svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; } @@ -91,12 +92,13 @@ button { gap: 0.5rem; font-weight: 520; font-size: 0.9rem; + line-height: 1.35; transition: background 0.15s, border-color 0.15s, opacity 0.15s; /* Labels may wrap when space runs out; compact buttons below stay on one line. */ white-space: normal; max-width: 100%; } -button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; } +button.icon, button.tool { white-space: nowrap; } button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); } button:active { background: color-mix(in srgb, var(--text) 14%, transparent); } button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index 027afad..33654cb 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -170,7 +170,7 @@ async function mockInvokeInner(cmd: string, args: Record): Promise< return [{ id: 1, server_id: 1, server_name: 'Survival SMP', from_uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', from_name: 'Notch', to_uuid: 'friend-1', to_name: 'Alex', amount: 150, description: 'Payment to Alex', created_at: '2026-09-29T15:30:00Z' }]; case 'bootstrap': { const b: Bootstrap = { - app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' }, + app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'https://gitea.jadonsandbox.com/blade/scopenet-mc', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' }, panel_url: settings.panel_url, settings, accounts, diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 08d4659..d409517 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -259,6 +259,7 @@ pub fn api(state: &AppState) -> Router { .route("/map/tiles", post(worldmap::game_tiles)) .route("/map/players", post(worldmap::game_players)) .route("/map/overlay", post(worldmap::game_overlay)) + .route("/map/diagnostics", post(worldmap::game_diagnostics)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)); Router::new() diff --git a/panel/server/src/routes/worldmap.rs b/panel/server/src/routes/worldmap.rs index 04920cf..53f4c20 100644 --- a/panel/server/src/routes/worldmap.rs +++ b/panel/server/src/routes/worldmap.rs @@ -89,6 +89,19 @@ pub async fn game_overlay( Ok(Json(json!({ "ok": true }))) } +pub async fn game_diagnostics( + GameServer(server): GameServer, + State(state): State, + Json(value): Json, +) -> AppResult> { + require_enabled(server.map_enabled)?; + if !value.is_object() || value.to_string().len() > 4096 { + return Err(AppError::bad_request("invalid map diagnostics")); + } + state.worldmap.set_diagnostics(server.id, value); + Ok(Json(json!({ "ok": true }))) +} + // --------------------------------------------------------------------------- // Launcher and admin viewers // --------------------------------------------------------------------------- @@ -143,6 +156,7 @@ pub async fn admin_status(_: AdminUser, State(state): State, Path(id): v["stats"] = serde_json::to_value(state.worldmap.stats(id))?; v["epoch"] = json!(state.worldmap.epoch(id)); v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?; + v["diagnostics"] = state.worldmap.diagnostics(id); Ok(Json(v)) } diff --git a/panel/server/src/worldmap.rs b/panel/server/src/worldmap.rs index 34f8357..9539d10 100644 --- a/panel/server/src/worldmap.rs +++ b/panel/server/src/worldmap.rs @@ -56,6 +56,7 @@ pub struct WorldMap { tokens: Mutex>, overlays: Mutex>, stats: Mutex>, + diagnostics: Mutex>, } /// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space"). @@ -133,6 +134,7 @@ impl WorldMap { tokens: Default::default(), overlays: Default::default(), stats: Default::default(), + diagnostics: Default::default(), } } @@ -217,6 +219,7 @@ impl WorldMap { std::fs::write(dir.join("epoch"), epoch.to_string())?; self.overlays.lock().unwrap().remove(&id); *self.stats.lock().unwrap().entry(id).or_default() = Stats::default(); + self.diagnostics.lock().unwrap().remove(&id); Ok(()) } @@ -231,12 +234,30 @@ impl WorldMap { self.players.lock().unwrap().remove(&id); self.overlays.lock().unwrap().remove(&id); self.stats.lock().unwrap().remove(&id); + self.diagnostics.lock().unwrap().remove(&id); } pub fn stats(&self, id: i64) -> Stats { self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() } + pub fn set_diagnostics(&self, id: i64, value: Value) { + self.diagnostics.lock().unwrap().insert(id, (Instant::now(), value)); + } + + pub fn diagnostics(&self, id: i64) -> Value { + self.diagnostics + .lock() + .unwrap() + .get(&id) + .map(|(at, value)| { + let mut value = value.clone(); + value["age_seconds"] = json!(at.elapsed().as_secs()); + value + }) + .unwrap_or(Value::Null) + } + // ---- players ------------------------------------------------------------------------- pub fn set_players(&self, id: i64, mut players: Vec) { diff --git a/panel/server/tests/worldmap.rs b/panel/server/tests/worldmap.rs index fba5341..9529c7d 100644 --- a/panel/server/tests/worldmap.rs +++ b/panel/server/tests/worldmap.rs @@ -117,6 +117,20 @@ async fn players_and_overlay_are_served_to_viewers() { assert_eq!(info["players"], 1); } +#[tokio::test] +async fn map_diagnostics_are_admin_only_and_reset_with_tiles() { + let w = world().await; + let payload = json!({"stage":"waiting for readable chunks","error":"No readable full chunks","region_files":1}); + assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", Some(&w.server), Some(payload)).await.0, StatusCode::OK); + assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", None, Some(json!({}))).await.0, StatusCode::UNAUTHORIZED); + let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await; + assert_eq!(status["diagnostics"]["stage"], "waiting for readable chunks"); + assert!(status["diagnostics"]["age_seconds"].is_number()); + w.t.call("DELETE", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await; + let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await; + assert!(status["diagnostics"].is_null()); +} + #[tokio::test] async fn the_map_can_be_switched_off_and_reset() { let w = world().await; diff --git a/panel/web/src/app.css b/panel/web/src/app.css index 20d60c4..c933f71 100644 --- a/panel/web/src/app.css +++ b/panel/web/src/app.css @@ -38,10 +38,10 @@ body { -webkit-font-smoothing: antialiased; } -body { background: var(--bg); overflow-wrap: anywhere; } +body { background: var(--bg); line-height: 1.5; overflow-wrap: normal; } /* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */ -*, *::before, *::after { min-width: 0; } +.row > *, .grid > *, .card > *, .page-head > *, .section-title > * { min-width: 0; } svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; } @@ -147,12 +147,14 @@ button, .btn { justify-content: center; gap: 8px; font-weight: 520; + line-height: 1.35; transition: background 0.15s, border-color 0.15s, opacity 0.15s; /* Labels may wrap when space runs out; compact buttons below stay on one line. */ white-space: normal; + overflow-wrap: normal; max-width: 100%; } -button.primary, button.ghost, button.danger, button.icon, .btn { white-space: nowrap; } +button.icon { white-space: nowrap; } button:hover { background: var(--surface-3); } button:disabled { opacity: 0.5; cursor: not-allowed; } button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { @@ -204,6 +206,7 @@ label.field { font-size: 0.85rem; color: var(--text-2); font-weight: 500; + line-height: 1.45; } label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; } @@ -278,6 +281,7 @@ table.table { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; + line-height: 1.45; } .table tr:last-child td { border-bottom: none; } .table tbody tr { transition: background 0.1s; } diff --git a/panel/web/src/components/MapPanel.svelte b/panel/web/src/components/MapPanel.svelte index 08af75a..0859b1f 100644 --- a/panel/web/src/components/MapPanel.svelte +++ b/panel/web/src/components/MapPanel.svelte @@ -72,6 +72,21 @@
Last tiles{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}
Last players{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}
+
+ {#if status?.diagnostics} +
Game server: {status.diagnostics.stage} 30}>{status.diagnostics.age_seconds > 30 ? 'No recent report' : `Reported ${status.diagnostics.age_seconds}s ago`}
+
+ Region files {status.diagnostics.region_files} + Rendered {status.diagnostics.regions_rendered} + Queued tiles {status.diagnostics.queued_tiles} + Uploaded {status.diagnostics.tiles_uploaded} +
+ {#if status.diagnostics.error}

{status.diagnostics.error}

{/if} +
Paths and upload details

World: {status.diagnostics.world_root}

Last region: {status.diagnostics.last_region || '—'}

Last overlay: {status.stats.last_overlay_at ? timeAgo(status.stats.last_overlay_at) : 'never'}

+ {:else} +

No map report from the game server yet. Check /map in game, the server's map.enabled setting, and its panel token.

+ {/if} +
{#if status?.dimensions.length}

{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}

{/if} @@ -108,6 +123,15 @@ .stats > div { display: flex; flex-direction: column; gap: 2px; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.78rem; color: var(--muted); } .stats strong { color: var(--text); font-size: 1.05rem; } .dims { margin: 0; } + .diagnostics { padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); line-height: 1.5; } + .diagnostic-head { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } + .diagnostic-head span { color: var(--good); font-size: 0.82rem; } + .diagnostic-head span.stale, .diagnostic-error { color: var(--bad); } + .diagnostic-grid { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0.5rem 0; color: var(--muted); font-size: 0.85rem; } + .diagnostic-grid strong { color: var(--text); margin-left: 0.25rem; } + .diagnostics details { margin-top: 0.4rem; color: var(--muted); font-size: 0.85rem; } + .diagnostics summary { cursor: pointer; } + .diagnostics code { overflow-wrap: anywhere; } .stage { height: min(68vh, 640px); min-height: 380px; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); --map-accent: var(--accent); } .pcard { height: 100%; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; border-radius: 18px; background: rgba(14, 19, 34, 0.94); border: 1px solid rgba(255,255,255,0.12); backdrop-filter: blur(14px); color: #f4f6ff; position: relative; align-self: flex-start; height: auto; width: 100%; } .pcard .who { display: flex; gap: 0.7rem; align-items: center; } diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index d441314..43ca26f 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -50,7 +50,7 @@ View landing page - + Documentation diff --git a/panel/web/src/components/TokenReveal.svelte b/panel/web/src/components/TokenReveal.svelte index 6e70263..90c8db6 100644 --- a/panel/web/src/components/TokenReveal.svelte +++ b/panel/web/src/components/TokenReveal.svelte @@ -23,7 +23,7 @@
  • Install the SCOPENET integration Use scopenet-paper for Paper, Purpur or Spigot. Fabric and Forge need the JAR for the exact Minecraft version: 1.20.1, 1.21.1, 26.1.2, 26.2 or 26.3. - Download from Releases + Download from Releases
  • Configure it diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 2dfe7c3..5cf55d4 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -152,6 +152,7 @@ export interface MapStatus { players: number; epoch: number; stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null }; + diagnostics: { stage: string; error: string; world_root: string; region_files: number; regions_rendered: number; queued_tiles: number; tiles_uploaded: number; last_region: string; last_upload_ms: number; age_seconds: number } | null; roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; } diff --git a/panel/web/src/pages/LandingBuilder.svelte b/panel/web/src/pages/LandingBuilder.svelte index dffc6fb..8868735 100644 --- a/panel/web/src/pages/LandingBuilder.svelte +++ b/panel/web/src/pages/LandingBuilder.svelte @@ -230,7 +230,7 @@ {:else if tab === 'downloads'}

    Launcher downloads

    Upload installers or point players to an external release page.

    - + {#each ['windows', 'mac', 'linux'] as platform} {@const download = config.hosted_downloads.find((d) => d.platform === platform)}
    {platform[0].toUpperCase() + platform.slice(1)}

    {download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}

    diff --git a/panel/web/src/pages/Settings.svelte b/panel/web/src/pages/Settings.svelte index 588c1d1..9fdf76c 100644 --- a/panel/web/src/pages/Settings.svelte +++ b/panel/web/src/pages/Settings.svelte @@ -186,7 +186,7 @@ {/if} diff --git a/shared/map/MapViewer.svelte b/shared/map/MapViewer.svelte index 0a41bd3..bdc5bb9 100644 --- a/shared/map/MapViewer.svelte +++ b/shared/map/MapViewer.svelte @@ -37,9 +37,11 @@ let zoomText = $state(''); let listOpen = $state(false); let following = $state(null); + let hiddenPlayers = $state([]); + let showSkins = $state(true); const dims = $derived((info?.dimensions ?? []).filter((d) => d.available)); - const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim)); + const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim && !hiddenPlayers.includes(p.uuid))); const everyone = $derived(overlay?.players ?? []); const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined); const waiting = $derived(!info || !info.ready); @@ -59,13 +61,15 @@ }); $effect(() => { if (view && info) { view.setInfo(info); dim = view.currentDimension?.slug ?? ''; } }); - $effect(() => { if (view && overlay) view.setOverlay(overlay); }); + $effect(() => { if (view && overlay) view.setOverlay({ ...overlay, players: overlay.players.filter((p) => !hiddenPlayers.includes(p.uuid)) }); }); $effect(() => { view?.setLayers(layers); }); + $effect(() => { view?.setSkins(showSkins); }); $effect(() => { view?.setSelf(selfUuid); }); $effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); }); function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; } function goTo(p: LivePlayer) { + if (hiddenPlayers.includes(p.uuid)) hiddenPlayers = hiddenPlayers.filter((id) => id !== p.uuid); if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension); view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2)); view?.select({ type: 'player', player: p }); @@ -76,6 +80,10 @@ view?.follow(following); } function findMe() { if (me) { goTo(me); } } + function togglePlayer(uuid: string) { + hiddenPlayers = hiddenPlayers.includes(uuid) ? hiddenPlayers.filter((id) => id !== uuid) : [...hiddenPlayers, uuid]; + if (hiddenPlayers.includes(uuid) && selection?.type === 'player' && selection.player.uuid === uuid) closeCard(); + } function toSpawn() { const spawn = view?.pinsOfKind('spawn')[0]; if (spawn) view?.flyTo(spawn.x, spawn.z, 1); @@ -118,8 +126,10 @@ {/if} {#if listOpen && everyone.length}
      +
    • {#if avatarUrl}{/if}
    • {#each everyone as p (p.uuid)}
    • + togglePlayer(p.uuid)} />