Migrate CI to Gitea and improve live map diagnostics
CI / Real Minecraft installs (manual) (push) Skipped
CI / Rust, web and container (push) Canceled after 0s
CI / Windows launcher (push) Canceled after 0s
Server integrations / paper (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.20.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.21.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.1.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.3 (push) Canceled after 0s

This commit is contained in:
SCOPEDD committed 2026-10-01 00:07:35 -04:00
1 parent d72d7c2e80
commit 7809d47d39
32 files changed
+518 -325

No files matched your search

+93
View File
@@ -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 '<!doctype html>' > 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
+88
View File
@@ -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
@@ -1,6 +1,6 @@
name: Release 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). # or from the Actions tab → Release → Run workflow (enter a version).
# #
# Optional repository variables (Settings → Secrets and variables → Actions → Variables): # Optional repository variables (Settings → Secrets and variables → Actions → Variables):
@@ -18,7 +18,8 @@ on:
required: true required: true
permissions: permissions:
contents: write contents: read
releases: write
packages: write packages: write
env: env:
@@ -26,31 +27,44 @@ env:
jobs: jobs:
version: version:
runs-on: ubuntu-24.04 runs-on: linux-build
outputs: outputs:
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
tag: ${{ steps.v.outputs.tag }} tag: ${{ steps.v.outputs.tag }}
steps: steps:
- name: Verify Linux runner
run: |
test "$(uname -s)" = Linux
command -v bash
- id: v - id: v
shell: bash shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: | 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}" 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 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 "version=$v" >> "$GITEA_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT" echo "tag=v$v" >> "$GITEA_OUTPUT"
launcher: launcher:
name: Windows installer name: Windows installer
needs: version needs: version
runs-on: windows-latest runs-on: windows-build
steps: 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: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
key: release key: release
- uses: actions/setup-node@v5 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
@@ -74,7 +88,7 @@ jobs:
env: env:
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }} SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }} SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
SCOPENET_REPO: ${{ github.repository }} SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }}
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }} LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
run: | run: |
if [ -n "$LAUNCHER_NAME" ]; then if [ -n "$LAUNCHER_NAME" ]; then
@@ -98,21 +112,27 @@ jobs:
if-no-files-found: error if-no-files-found: error
panel-image: panel-image:
name: Panel image (GHCR) name: Panel image (Gitea registry)
needs: version needs: version
runs-on: ubuntu-24.04 runs-on: linux-build
steps: steps:
- uses: actions/checkout@v5 - uses: builtin:checkout
- name: Image name (GHCR needs lowercase) - name: Verify container runner
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV" 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 - name: Stamp version
run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: gitea.jadonsandbox.com
username: ${{ github.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITEA_TOKEN }}
- id: meta - id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
@@ -128,20 +148,25 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=gha,mode=max cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
server-integrations: server-integrations:
needs: version needs: version
uses: ./.github/workflows/integrations.yml uses: ./.gitea/workflows/integrations.yml
with: with:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
release: release:
name: GitHub release name: Gitea release
needs: [version, launcher, panel-image, server-integrations] needs: [version, launcher, panel-image, server-integrations]
runs-on: ubuntu-24.04 runs-on: linux-build
steps: steps:
- name: Verify release runner
run: |
test "$(uname -s)" = Linux
command -v curl
command -v jq
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: windows-installer name: windows-installer
@@ -153,7 +178,7 @@ jobs:
path: dist path: dist
- name: Release notes - name: Release notes
run: | run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" IMAGE="${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
cat > notes.md <<NOTES cat > notes.md <<NOTES
## Downloads ## Downloads
@@ -167,14 +192,22 @@ jobs:
\`\`\` \`\`\`
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`. Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
NOTES NOTES
- uses: softprops/action-gh-release@v2 - name: Publish Gitea release and assets
with: env:
tag_name: ${{ needs.version.outputs.tag }} TOKEN: ${{ secrets.GITEA_TOKEN }}
name: ${{ needs.version.outputs.tag }} TAG: ${{ needs.version.outputs.tag }}
target_commitish: ${{ github.sha }} VERSION: ${{ needs.version.outputs.version }}
body_path: notes.md run: |
generate_release_notes: true set -euo pipefail
prerelease: ${{ contains(needs.version.outputs.version, '-') }} command -v curl && command -v jq
files: | test -n "$TOKEN" || { echo 'GITEA_TOKEN is unavailable'; exit 1; }
dist/*-setup.exe api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
dist/*.jar body=$(jq -n --arg tag "$TAG" --arg notes "$(cat notes.md)" --arg sha "$GITHUB_SHA" --argjson pre "$(case "$VERSION" in *-*) echo true;; *) echo false;; esac)" '{tag_name:$tag,name:$tag,body:$notes,target_commitish:$sha,prerelease:$pre}')
release=$(curl -fsS -H "Authorization: token $TOKEN" "$api/tags/$TAG") || \
release=$(curl --fail-with-body -sS -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d "$body" "$api")
id=$(printf '%s' "$release" | jq -er '.id')
for file in dist/*-setup.exe dist/*.jar; do
test -f "$file" || continue
if printf '%s' "$release" | jq -e --arg name "$(basename "$file")" '.assets[]? | select(.name == $name)' >/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
-100
View File
@@ -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 '<!doctype html>' > 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
-141
View File
@@ -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
+11 -7
View File
@@ -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. - **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. - **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. - **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. - **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 ### Admin panel features
@@ -52,8 +52,8 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
### 1. Run the panel ### 1. Run the panel
```bash ```bash
git clone https://github.com/scopeddlol/SCOPENET-MC.git git clone https://gitea.jadonsandbox.com/blade/scopenet-mc.git
cd SCOPENET-MC cd scopenet-mc
cp .env.example .env cp .env.example .env
# edit .env → set ADMIN_PASSWORD and PUBLIC_URL for your domain # edit .env → set ADMIN_PASSWORD and PUBLIC_URL for your domain
docker compose config --quiet docker compose config --quiet
@@ -70,15 +70,19 @@ Open `http://your-server:8080`, sign in as `admin`, then:
### 2. Build your branded launcher ### 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) - `PANEL_URL` = `https://panel.example.com` (players connect automatically)
- `LOCK_PANEL` = `1` (optional: hide the "change server" option) - `LOCK_PANEL` = `1` (optional: hide the "change server" option)
- `LAUNCHER_NAME` = `MyServer Launcher` (optional) - `LAUNCHER_NAME` = `MyServer Launcher` (optional)
2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`. 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/<owner>/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 ## Repository layout
@@ -92,7 +96,7 @@ launcher/ Desktop launcher UI (Svelte 5)
launcher/src-tauri Desktop launcher shell (Tauri 2) launcher/src-tauri Desktop launcher shell (Tauri 2)
Dockerfile Panel image (static binary on scratch) Dockerfile Panel image (static binary on scratch)
docker-compose.yml Panel deployment 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 ## Documentation
+1 -1
View File
@@ -80,7 +80,7 @@ Everything on this page is pushed to launchers when they start or refresh — no
## Releasing the launcher ## 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`. **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`.
+4 -4
View File
@@ -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. 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. 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 ## 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 ## 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 ## 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 ## 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). - **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. - **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour.
@@ -175,6 +175,11 @@ public final class Integration implements AutoCloseable {
if (current != null) current.requestScan(); 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) { public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
return client.checkChunk(dimension, chunkX, chunkZ, uuid); return client.checkChunk(dimension, chunkX, chunkZ, uuid);
} }
@@ -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"); } 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. */ /** The tile's pixels, or null if it was never rendered. */
public synchronized int[] load(Key k) { public synchronized int[] load(Key k) {
int[] cached = memory.get(k); int[] cached = memory.get(k);
@@ -68,6 +68,14 @@ public final class WorldMapSync implements AutoCloseable {
private long overlaySentAt; private long overlaySentAt;
private String lastPlayersSent = ""; private String lastPlayersSent = "";
private long playersSentAt; 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<String> log) { public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
this.client = client; this.client = client;
@@ -90,6 +98,15 @@ public final class WorldMapSync implements AutoCloseable {
players = List.copyOf(next); 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. */ /** 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<String> source) { public void setOverlay(java.util.function.Supplier<String> source) {
overlay = source; overlay = source;
@@ -126,12 +143,17 @@ public final class WorldMapSync implements AutoCloseable {
} catch (PanelClient.HttpFailure e) { } catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false; if (e.status == 403 || e.status == 404) enabled = false;
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")"); 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; pause = 30_000;
} catch (Exception e) { } catch (Exception e) {
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
stage = "failed";
pause = 60_000; pause = 60_000;
} }
if (stateDirty) saveState(); if (stateDirty) saveState();
queuedTiles = state.dirty.size();
try { try {
synchronized (wake) { synchronized (wake) {
if (!wakeRequested) wake.wait(pause); 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."); if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
enabled = on; enabled = on;
stage = on ? "scanning regions" : "disabled on panel";
if (on) lastError = "";
} catch (PanelClient.HttpFailure e) { } catch (PanelClient.HttpFailure e) {
enabled = false; // an older panel without the map enabled = false; // an older panel without the map
stage = "panel configuration failed";
lastError = "panel HTTP " + e.status + ": " + e.getMessage();
} }
return enabled; 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. */ /** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
private boolean cycle() throws IOException, InterruptedException { private boolean cycle() throws IOException, InterruptedException {
if (!Files.isDirectory(worldRoot)) { if (!Files.isDirectory(worldRoot)) {
stage = "world folder missing";
if (!warnedMissing) { if (!warnedMissing) {
warnedMissing = true; warnedMissing = true;
log.accept("SCOPENET Map: world folder " + worldRoot + " not found"); log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
@@ -181,12 +208,15 @@ public final class WorldMapSync implements AutoCloseable {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {} record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
List<Stale> stale = new ArrayList<>(); List<Stale> stale = new ArrayList<>();
int filesFound = 0;
int completeFiles = 0;
List<Player> online = players; List<Player> online = players;
for (Dim dim : DIMS) { for (Dim dim : DIMS) {
Path dir = regionDir(dim); Path dir = regionDir(dim);
if (dir == null) continue; if (dir == null) continue;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
for (Path file : stream) { for (Path file : stream) {
filesFound++;
String[] parts = file.getFileName().toString().split("\\."); String[] parts = file.getFileName().toString().split("\\.");
int rx, rz; int rx, rz;
try { try {
@@ -195,13 +225,14 @@ public final class WorldMapSync implements AutoCloseable {
} catch (RuntimeException e) { } catch (RuntimeException e) {
continue; continue;
} }
if (Files.size(file) <= 8192) continue;
completeFiles++;
long mtime = Files.getLastModifiedTime(file).toMillis(); long mtime = Files.getLastModifiedTime(file).toMillis();
String key = dim.dir() + "/" + rx + "." + rz; String key = dim.dir() + "/" + rx + "." + rz;
Long done = state.regions.get(key); Long done = state.regions.get(key);
if (done != null && mtime <= done) continue; if (done != null && mtime <= done) continue;
Long last = renderedAt.get(key); Long last = renderedAt.get(key);
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue; if (last != null && now - last < RERENDER_GAP_MS) continue;
if (Files.size(file) < 8192) continue;
double distance = Double.MAX_VALUE; double distance = Double.MAX_VALUE;
for (Player p : online) { for (Player p : online) {
if (!dim.id().equals(p.dimension())) continue; 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; boolean worked = false;
if (!stale.isEmpty()) { if (!stale.isEmpty()) {
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key)); stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
Stale next = stale.get(0); Stale next = stale.get(0);
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz()); worked = renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
worked = true;
} }
if (flushUploads(stale.size() <= 1)) worked = true; if (flushUploads(stale.size() <= 1)) worked = true;
if (filesFound > 0 && stale.isEmpty() && state.dirty.isEmpty() && state.regions.size() >= filesFound) stage = "caught up";
return worked; 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; int[][] tiles;
try { try {
tiles = RegionRenderer.render(file, dim.kind()); tiles = RegionRenderer.render(file, dim.kind());
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
// A region being written right now; try again after the gap. // A region being written right now; try again after the gap.
renderedAt.put(key, System.currentTimeMillis()); renderedAt.put(key, System.currentTimeMillis());
return; lastError = "render " + key + ": " + e.getMessage();
return false;
} }
renderedAt.put(key, System.currentTimeMillis()); 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<TileStore.Key> changed = new ArrayList<>(); List<TileStore.Key> changed = new ArrayList<>();
if (tiles != null) { if (tiles != null) {
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
@@ -261,6 +306,7 @@ public final class WorldMapSync implements AutoCloseable {
} }
state.regions.put(key, mtime); state.regions.put(key, mtime);
stateDirty = true; 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. */ /** 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; if (inBatch.isEmpty()) continue;
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray()); 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()); for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
stateDirty = true; stateDirty = true;
sentAny = true; sentAny = true;
@@ -344,12 +393,34 @@ public final class WorldMapSync implements AutoCloseable {
lastOverlaySent = ov; lastOverlaySent = ov;
overlaySentAt = now; 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) { } catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false; if (e.status == 403 || e.status == 404) enabled = false;
} catch (Exception ignored) { lastError = "live upload HTTP " + e.status + ": " + e.getMessage();
// the next tick retries } catch (Exception e) {
lastError = "live upload: " + e.getMessage();
} }
} }
private long diagnosticsSentAt;
// ---- world layout ---- // ---- world layout ----
@@ -378,7 +449,23 @@ public final class WorldMapSync implements AutoCloseable {
Path f = cacheDir.resolve("state.json"); Path f = cacheDir.resolve("state.json");
if (Files.isRegularFile(f)) { if (Files.isRegularFile(f)) {
State s = gson.fromJson(Files.readString(f), State.class); 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) { } catch (IOException | RuntimeException e) {
state = new State(); state = new State();
@@ -19,7 +19,7 @@ final class ScopenetCommand implements CoreCommand {
@Override public String permission() { return "scopenet.command.scopenet"; } @Override public String permission() { return "scopenet.command.scopenet"; }
@Override public List<String> complete(CorePlayer player, String[] args) { @Override public List<String> 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) { @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; } if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
features.sendStatus(p); 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" -> { case "reload" -> {
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; } if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
features.reload(p); features.reload(p);
@@ -51,5 +56,6 @@ final class ScopenetCommand implements CoreCommand {
p.send(Format.YELLOW + " /" + c.name() + aliases); 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.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");
} }
} }
@@ -87,6 +87,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register /scopenet admin & help command // Register /scopenet admin & help command
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat); ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
bindCommand("scopenet", scopenetCmd); bindCommand("scopenet", scopenetCmd);
bindCommand("map", scopenetCmd);
// Register Essentials commands & GUI // Register Essentials commands & GUI
{ {
@@ -22,6 +22,10 @@ public final class ScopenetCommandHandler implements CommandExecutor {
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { 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 (args.length == 0 || args[0].equalsIgnoreCase("help")) {
if (!sender.hasPermission("scopenet.command.scopenet.help")) { if (!sender.hasPermission("scopenet.command.scopenet.help")) {
sender.sendMessage(ChatColor.RED + "You do not have permission to view 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(); String sub = args[0].toLowerCase();
switch (sub) { switch (sub) {
case "status" -> sendStatus(sender); case "status" -> sendStatus(sender);
case "map" -> sendMap(sender);
case "reload" -> handleReload(sender); case "reload" -> handleReload(sender);
default -> { default -> {
sender.sendMessage(ChatColor.RED + "Unknown command. Use " + ChatColor.YELLOW + "/scopenet help"); 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")) { if (sender.hasPermission("scopenet.admin")) {
sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]"); sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]");
sender.sendMessage(ChatColor.YELLOW + " /scopenet status" + ChatColor.GRAY + " - Integration health and module statuses"); 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"); 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) { private String formatFlag(boolean enabled) {
return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED"; return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED";
} }
@@ -11,6 +11,9 @@ commands:
permission: scopenet.command.scopenet permission: scopenet.command.scopenet
description: SCOPENET server commands, status, and help description: SCOPENET server commands, status, and help
aliases: [sn] aliases: [sn]
map:
permission: scopenet.command.scopenet.status
description: Show live map rendering and upload progress
spawn: spawn:
permission: scopenet.command.spawn permission: scopenet.command.spawn
description: Teleport to the server spawn point description: Teleport to the server spawn point
+3 -3
View File
@@ -6,17 +6,17 @@ use scopenet_shared::LauncherManifest;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Mutex, RwLock}; 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 { pub mod build {
/// Default panel URL for this build. /// Default panel URL for this build.
pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") { pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") {
Some(u) if !u.is_empty() => Some(u), Some(u) if !u.is_empty() => Some(u),
_ => Some("https://scopenetmcpanel.scopedd.lol"), _ => 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") { pub const REPO: Option<&str> = match option_env!("SCOPENET_REPO") {
Some(r) if !r.is_empty() => Some(r), 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. /// When set to "1", players can't change the panel URL.
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL"); pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
+10 -10
View File
@@ -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. //! it and hand over to the installer.
use crate::state::build; 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<Option<UpdateInfo>> { pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) }; let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
let release: Release = http let repo = reqwest::Url::parse(repo).context("invalid release repository URL")?;
.get(format!("https://api.github.com/repos/{repo}/releases/latest")) if repo.scheme() != "https" {
.header("Accept", "application/vnd.github+json") anyhow::bail!("release repository must use HTTPS");
.send() }
.await? let api = format!("{}/api/v1/repos{}/releases/latest", repo.origin().ascii_serialization(), repo.path());
.error_for_status()? let release: Release = http.get(api).header("Accept", "application/json").send().await?.error_for_status()?.json().await?;
.json()
.await?;
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) { if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
return Ok(None); return Ok(None);
} }
@@ -68,7 +66,9 @@ pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
/// Download the installer and start it. The caller exits the app afterwards. /// Download the installer and start it. The caller exits the app afterwards.
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> { 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"); anyhow::bail!("refusing to download an update from an unexpected host");
} }
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe"); let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
+5 -3
View File
@@ -41,13 +41,14 @@ html, body {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */ /* 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%; } #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. */ /* 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; } svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
@@ -91,12 +92,13 @@ button {
gap: 0.5rem; gap: 0.5rem;
font-weight: 520; font-weight: 520;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.35;
transition: background 0.15s, border-color 0.15s, opacity 0.15s; 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. */ /* Labels may wrap when space runs out; compact buttons below stay on one line. */
white-space: normal; white-space: normal;
max-width: 100%; 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:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); } button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
+1 -1
View File
@@ -170,7 +170,7 @@ async function mockInvokeInner(cmd: string, args: Record<string, any>): 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' }]; 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': { case 'bootstrap': {
const b: 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, panel_url: settings.panel_url,
settings, settings,
accounts, accounts,
+1
View File
@@ -259,6 +259,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/map/tiles", post(worldmap::game_tiles)) .route("/map/tiles", post(worldmap::game_tiles))
.route("/map/players", post(worldmap::game_players)) .route("/map/players", post(worldmap::game_players))
.route("/map/overlay", post(worldmap::game_overlay)) .route("/map/overlay", post(worldmap::game_overlay))
.route("/map/diagnostics", post(worldmap::game_diagnostics))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)); .layer(DefaultBodyLimit::max(10 * 1024 * 1024));
Router::new() Router::new()
+14
View File
@@ -89,6 +89,19 @@ pub async fn game_overlay(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
pub async fn game_diagnostics(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(value): Json<Value>,
) -> AppResult<Json<Value>> {
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 // Launcher and admin viewers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -143,6 +156,7 @@ pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id):
v["stats"] = serde_json::to_value(state.worldmap.stats(id))?; v["stats"] = serde_json::to_value(state.worldmap.stats(id))?;
v["epoch"] = json!(state.worldmap.epoch(id)); v["epoch"] = json!(state.worldmap.epoch(id));
v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?; v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?;
v["diagnostics"] = state.worldmap.diagnostics(id);
Ok(Json(v)) Ok(Json(v))
} }
+21
View File
@@ -56,6 +56,7 @@ pub struct WorldMap {
tokens: Mutex<HashMap<String, (i64, Instant)>>, tokens: Mutex<HashMap<String, (i64, Instant)>>,
overlays: Mutex<HashMap<i64, Value>>, overlays: Mutex<HashMap<i64, Value>>,
stats: Mutex<HashMap<i64, Stats>>, stats: Mutex<HashMap<i64, Stats>>,
diagnostics: Mutex<HashMap<i64, (Instant, Value)>>,
} }
/// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space"). /// 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(), tokens: Default::default(),
overlays: Default::default(), overlays: Default::default(),
stats: Default::default(), stats: Default::default(),
diagnostics: Default::default(),
} }
} }
@@ -217,6 +219,7 @@ impl WorldMap {
std::fs::write(dir.join("epoch"), epoch.to_string())?; std::fs::write(dir.join("epoch"), epoch.to_string())?;
self.overlays.lock().unwrap().remove(&id); self.overlays.lock().unwrap().remove(&id);
*self.stats.lock().unwrap().entry(id).or_default() = Stats::default(); *self.stats.lock().unwrap().entry(id).or_default() = Stats::default();
self.diagnostics.lock().unwrap().remove(&id);
Ok(()) Ok(())
} }
@@ -231,12 +234,30 @@ impl WorldMap {
self.players.lock().unwrap().remove(&id); self.players.lock().unwrap().remove(&id);
self.overlays.lock().unwrap().remove(&id); self.overlays.lock().unwrap().remove(&id);
self.stats.lock().unwrap().remove(&id); self.stats.lock().unwrap().remove(&id);
self.diagnostics.lock().unwrap().remove(&id);
} }
pub fn stats(&self, id: i64) -> Stats { pub fn stats(&self, id: i64) -> Stats {
self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() 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 ------------------------------------------------------------------------- // ---- players -------------------------------------------------------------------------
pub fn set_players(&self, id: i64, mut players: Vec<LivePlayer>) { pub fn set_players(&self, id: i64, mut players: Vec<LivePlayer>) {
+14
View File
@@ -117,6 +117,20 @@ async fn players_and_overlay_are_served_to_viewers() {
assert_eq!(info["players"], 1); 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] #[tokio::test]
async fn the_map_can_be_switched_off_and_reset() { async fn the_map_can_be_switched_off_and_reset() {
let w = world().await; let w = world().await;
+7 -3
View File
@@ -38,10 +38,10 @@ body {
-webkit-font-smoothing: antialiased; -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. */ /* 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; } svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
@@ -147,12 +147,14 @@ button, .btn {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
font-weight: 520; font-weight: 520;
line-height: 1.35;
transition: background 0.15s, border-color 0.15s, opacity 0.15s; 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. */ /* Labels may wrap when space runs out; compact buttons below stay on one line. */
white-space: normal; white-space: normal;
overflow-wrap: normal;
max-width: 100%; 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:hover { background: var(--surface-3); }
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
@@ -204,6 +206,7 @@ label.field {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-2); color: var(--text-2);
font-weight: 500; font-weight: 500;
line-height: 1.45;
} }
label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; } label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; }
@@ -278,6 +281,7 @@ table.table {
padding: 12px 14px; padding: 12px 14px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
vertical-align: middle; vertical-align: middle;
line-height: 1.45;
} }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tbody tr { transition: background 0.1s; } .table tbody tr { transition: background 0.1s; }
+24
View File
@@ -72,6 +72,21 @@
<div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div> <div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div>
<div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div> <div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div>
</div> </div>
<div class="diagnostics" role="status">
{#if status?.diagnostics}
<div class="diagnostic-head"><strong>Game server: {status.diagnostics.stage}</strong><span class:stale={status.diagnostics.age_seconds > 30}>{status.diagnostics.age_seconds > 30 ? 'No recent report' : `Reported ${status.diagnostics.age_seconds}s ago`}</span></div>
<div class="diagnostic-grid">
<span>Region files <strong>{status.diagnostics.region_files}</strong></span>
<span>Rendered <strong>{status.diagnostics.regions_rendered}</strong></span>
<span>Queued tiles <strong>{status.diagnostics.queued_tiles}</strong></span>
<span>Uploaded <strong>{status.diagnostics.tiles_uploaded}</strong></span>
</div>
{#if status.diagnostics.error}<p class="diagnostic-error">{status.diagnostics.error}</p>{/if}
<details><summary>Paths and upload details</summary><p>World: <code>{status.diagnostics.world_root}</code></p><p>Last region: {status.diagnostics.last_region || '—'}</p><p>Last overlay: {status.stats.last_overlay_at ? timeAgo(status.stats.last_overlay_at) : 'never'}</p></details>
{:else}
<p>No map report from the game server yet. Check <code>/map</code> in game, the server's <code>map.enabled</code> setting, and its panel token.</p>
{/if}
</div>
{#if status?.dimensions.length} {#if status?.dimensions.length}
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p> <p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
{/if} {/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 > 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; } .stats strong { color: var(--text); font-size: 1.05rem; }
.dims { margin: 0; } .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); } .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 { 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; } .pcard .who { display: flex; gap: 0.7rem; align-items: center; }
+1 -1
View File
@@ -50,7 +50,7 @@
<Globe size={14} /> View landing page <Globe size={14} /> View landing page
</a> </a>
<a class="docs" href="https://github.com/scopeddlol/SCOPENET-MC#readme" target="_blank" rel="noreferrer"> <a class="docs" href="https://gitea.jadonsandbox.com/blade/scopenet-mc#readme" target="_blank" rel="noreferrer">
<ExternalLink size={14} /> Documentation <ExternalLink size={14} /> Documentation
</a> </a>
+1 -1
View File
@@ -23,7 +23,7 @@
<li> <li>
<strong>Install the SCOPENET integration</strong> <strong>Install the SCOPENET integration</strong>
<span class="muted small">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.</span> <span class="muted small">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.</span>
<a class="dl" href="https://github.com/scopeddlol/SCOPENET-MC/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a> <a class="dl" href="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
</li> </li>
<li> <li>
<strong>Configure it</strong> <strong>Configure it</strong>
+1
View File
@@ -152,6 +152,7 @@ export interface MapStatus {
players: number; players: number;
epoch: 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 }; 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 }[]; roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
} }
+1 -1
View File
@@ -230,7 +230,7 @@
</div> </div>
{:else if tab === 'downloads'} {:else if tab === 'downloads'}
<div class="settings-grid card"><h2>Launcher downloads</h2><p>Upload installers or point players to an external release page.</p> <div class="settings-grid card"><h2>Launcher downloads</h2><p>Upload installers or point players to an external release page.</p>
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://github.com/.../releases" /></label> <label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases" /></label>
{#each ['windows', 'mac', 'linux'] as platform} {#each ['windows', 'mac', 'linux'] as platform}
{@const download = config.hosted_downloads.find((d) => d.platform === platform)} {@const download = config.hosted_downloads.find((d) => d.platform === platform)}
<div class="download-row"><div><strong>{platform[0].toUpperCase() + platform.slice(1)}</strong><p>{download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}</p></div><label class="btn secondary sm"><Upload size={15} /> {uploading ? 'Uploading…' : 'Upload installer'}<input type="file" hidden disabled={uploading} onchange={(e) => upload(platform, e.currentTarget.files?.[0])} /></label></div> <div class="download-row"><div><strong>{platform[0].toUpperCase() + platform.slice(1)}</strong><p>{download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}</p></div><label class="btn secondary sm"><Upload size={15} /> {uploading ? 'Uploading…' : 'Upload installer'}<input type="file" hidden disabled={uploading} onchange={(e) => upload(platform, e.currentTarget.files?.[0])} /></label></div>
+1 -1
View File
@@ -186,7 +186,7 @@
{/if} {/if}
</label> </label>
<label class="field">Launcher download link <label class="field">Launcher download link
<input bind:value={s.launcher_download_url} placeholder="https://github.com/you/repo/releases/latest" /> <input bind:value={s.launcher_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" />
<span class="help">Shown on the dashboard so you can share it easily.</span> <span class="help">Shown on the dashboard so you can share it easily.</span>
</label> </label>
</section> </section>
+17 -2
View File
@@ -37,9 +37,11 @@
let zoomText = $state(''); let zoomText = $state('');
let listOpen = $state(false); let listOpen = $state(false);
let following = $state<string | null>(null); let following = $state<string | null>(null);
let hiddenPlayers = $state<string[]>([]);
let showSkins = $state(true);
const dims = $derived((info?.dimensions ?? []).filter((d) => d.available)); 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 everyone = $derived(overlay?.players ?? []);
const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined); const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined);
const waiting = $derived(!info || !info.ready); const waiting = $derived(!info || !info.ready);
@@ -59,13 +61,15 @@
}); });
$effect(() => { if (view && info) { view.setInfo(info); dim = view.currentDimension?.slug ?? ''; } }); $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?.setLayers(layers); });
$effect(() => { view?.setSkins(showSkins); });
$effect(() => { view?.setSelf(selfUuid); }); $effect(() => { view?.setSelf(selfUuid); });
$effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); }); $effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); });
function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; } function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; }
function goTo(p: LivePlayer) { function goTo(p: LivePlayer) {
if (hiddenPlayers.includes(p.uuid)) hiddenPlayers = hiddenPlayers.filter((id) => id !== p.uuid);
if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension); if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension);
view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2)); view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2));
view?.select({ type: 'player', player: p }); view?.select({ type: 'player', player: p });
@@ -76,6 +80,10 @@
view?.follow(following); view?.follow(following);
} }
function findMe() { if (me) { goTo(me); } } 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() { function toSpawn() {
const spawn = view?.pinsOfKind('spawn')[0]; const spawn = view?.pinsOfKind('spawn')[0];
if (spawn) view?.flyTo(spawn.x, spawn.z, 1); if (spawn) view?.flyTo(spawn.x, spawn.z, 1);
@@ -118,8 +126,10 @@
</button>{/if} </button>{/if}
{#if listOpen && everyone.length} {#if listOpen && everyone.length}
<ul class="plist" aria-label="Players online"> <ul class="plist" aria-label="Players online">
<li class="options">{#if avatarUrl}<label><input type="checkbox" bind:checked={showSkins} /> Show skins</label>{/if}<button onclick={() => (hiddenPlayers = hiddenPlayers.length ? [] : everyone.map((p) => p.uuid))}>{hiddenPlayers.length ? 'Show all' : 'Hide all'}</button></li>
{#each everyone as p (p.uuid)} {#each everyone as p (p.uuid)}
<li> <li>
<input type="checkbox" aria-label={`Show ${p.name} on map`} checked={!hiddenPlayers.includes(p.uuid)} onchange={() => togglePlayer(p.uuid)} />
<button onclick={() => goTo(p)}> <button onclick={() => goTo(p)}>
{#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if} {#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if}
<span class="nm">{p.name}</span> <span class="nm">{p.name}</span>
@@ -204,6 +214,11 @@
.right { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } .right { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.plist { list-style: none; margin: 0; padding: 6px; width: 230px; max-height: 320px; overflow: auto; border-radius: 16px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(14px); box-shadow: 0 14px 40px rgba(0,0,0,0.45); } .plist { list-style: none; margin: 0; padding: 6px; width: 230px; max-height: 320px; overflow: auto; border-radius: 16px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(14px); box-shadow: 0 14px 40px rgba(0,0,0,0.45); }
.plist button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: 600 13px/1 inherit; cursor: pointer; text-align: left; } .plist button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: 600 13px/1 inherit; cursor: pointer; text-align: left; }
.plist li { display: flex; align-items: center; gap: 3px; }
.plist input { accent-color: var(--map-accent, #8b6cff); flex: 0 0 auto; }
.plist .options { justify-content: space-between; padding: 6px 9px; border-bottom: 1px solid var(--map-line, rgba(255,255,255,0.12)); margin-bottom: 3px; font-size: 12px; }
.plist .options label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.plist .options button { width: auto; padding: 4px 6px; font-size: 12px; }
.plist button:hover { background: rgba(255,255,255,0.08); } .plist button:hover { background: rgba(255,255,255,0.08); }
.plist img { border-radius: 6px; image-rendering: pixelated; } .plist img { border-radius: 6px; image-rendering: pixelated; }
.nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+3 -1
View File
@@ -42,6 +42,7 @@ export class MapView {
private dimension: MapDimension | null = null; private dimension: MapDimension | null = null;
private overlay: MapOverlay = { players: [], claims: [], pins: [] }; private overlay: MapOverlay = { players: [], claims: [], pins: [] };
private layers: MapLayers = { claims: true, pins: true, players: true }; private layers: MapLayers = { claims: true, pins: true, players: true };
private showSkins = true;
private tiles = new Map<string, Tile>(); private tiles = new Map<string, Tile>();
private queue: string[] = []; private queue: string[] = [];
private loading = 0; private loading = 0;
@@ -117,6 +118,7 @@ export class MapView {
} }
setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); } setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); }
setSkins(show: boolean) { this.showSkins = show; this.invalidate(); }
setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); } setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); }
@@ -611,7 +613,7 @@ export class MapView {
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2); ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
ctx.fillStyle = ring; ctx.fill(); ctx.fillStyle = ring; ctx.fill();
ctx.restore(); ctx.restore();
const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null; const img = this.showSkins && this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
ctx.save(); ctx.save();
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip(); ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip();
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); } if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); }