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
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:
1 parent
d72d7c2e80
commit
7809d47d39
32 files changed
+518
-325
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
# 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 <<NOTES
|
||||
## 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\`.
|
||||
NOTES
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: ${{ needs.version.outputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
body_path: notes.md
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
|
||||
files: |
|
||||
dist/*-setup.exe
|
||||
dist/*.jar
|
||||
- name: Publish Gitea release and assets
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ needs.version.outputs.tag }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v curl && command -v jq
|
||||
test -n "$TOKEN" || { echo 'GITEA_TOKEN is unavailable'; exit 1; }
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
+4
-4
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> 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<String> 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> stale = new ArrayList<>();
|
||||
int filesFound = 0;
|
||||
int completeFiles = 0;
|
||||
List<Player> online = players;
|
||||
for (Dim dim : DIMS) {
|
||||
Path dir = regionDir(dim);
|
||||
if (dir == null) continue;
|
||||
try (DirectoryStream<Path> 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<TileStore.Key> 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();
|
||||
|
||||
+7
-1
@@ -19,7 +19,7 @@ final class ScopenetCommand implements CoreCommand {
|
||||
@Override public String permission() { return "scopenet.command.scopenet"; }
|
||||
|
||||
@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) {
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+14
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Option<UpdateInfo>> {
|
||||
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<Option<UpdateInfo>> {
|
||||
|
||||
/// 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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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' }];
|
||||
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,
|
||||
|
||||
@@ -259,6 +259,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.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()
|
||||
|
||||
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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["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))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct WorldMap {
|
||||
tokens: Mutex<HashMap<String, (i64, Instant)>>,
|
||||
overlays: Mutex<HashMap<i64, Value>>,
|
||||
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").
|
||||
@@ -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<LivePlayer>) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></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}
|
||||
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
|
||||
{/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; }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<Globe size={14} /> View landing page
|
||||
</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
|
||||
</a>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<li>
|
||||
<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>
|
||||
<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>
|
||||
<strong>Configure it</strong>
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
</div>
|
||||
{: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>
|
||||
<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}
|
||||
{@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>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
{/if}
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
@@ -37,9 +37,11 @@
|
||||
let zoomText = $state('');
|
||||
let listOpen = $state(false);
|
||||
let following = $state<string | null>(null);
|
||||
let hiddenPlayers = $state<string[]>([]);
|
||||
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 @@
|
||||
</button>{/if}
|
||||
{#if listOpen && everyone.length}
|
||||
<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)}
|
||||
<li>
|
||||
<input type="checkbox" aria-label={`Show ${p.name} on map`} checked={!hiddenPlayers.includes(p.uuid)} onchange={() => togglePlayer(p.uuid)} />
|
||||
<button onclick={() => goTo(p)}>
|
||||
{#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if}
|
||||
<span class="nm">{p.name}</span>
|
||||
@@ -204,6 +214,11 @@
|
||||
.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 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 img { border-radius: 6px; image-rendering: pixelated; }
|
||||
.nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -42,6 +42,7 @@ export class MapView {
|
||||
private dimension: MapDimension | null = null;
|
||||
private overlay: MapOverlay = { players: [], claims: [], pins: [] };
|
||||
private layers: MapLayers = { claims: true, pins: true, players: true };
|
||||
private showSkins = true;
|
||||
private tiles = new Map<string, Tile>();
|
||||
private queue: string[] = [];
|
||||
private loading = 0;
|
||||
@@ -117,6 +118,7 @@ export class MapView {
|
||||
}
|
||||
|
||||
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(); }
|
||||
|
||||
@@ -611,7 +613,7 @@ export class MapView {
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ring; ctx.fill();
|
||||
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.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); }
|
||||
|
||||
Reference in new issue
Block a user