Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeeaa16f40 | ||
|
|
d3057a2fc5 | ||
|
|
78bd14318d | ||
|
|
a378f9904c | ||
|
|
23ab34ab72 | ||
|
|
3632d4b67d | ||
|
|
571a754e4e | ||
|
|
bdb13b890d | ||
|
|
7809d47d39 | ||
|
|
d72d7c2e80 | ||
|
|
60edc99780 | ||
|
|
ce49447b6e | ||
|
|
deabea60ca | ||
|
|
7eea617225 | ||
|
|
aae0a5a5ff | ||
|
|
cd985a1a7b | ||
|
|
2cc009d66f | ||
|
|
f9b0dcedff | ||
|
|
6fd78f32ea | ||
|
|
6c58042a32 | ||
|
|
8e1f04c867 | ||
|
|
74ca07fe93 | ||
|
|
280f9eaf1b | ||
|
|
19d111eeff | ||
|
|
c859628e93 | ||
|
|
0dc948caa4 | ||
|
|
209ade0119 | ||
|
|
c37cde5cc8 | ||
|
|
f428bd210f | ||
|
|
5346b1f560 | ||
|
|
d98cc61ee6 | ||
|
|
ead4a706c7 | ||
|
|
30b1d92437 | ||
|
|
dbe02dc9bf | ||
|
|
9102e2fc52 |
No files matched your search
@@ -0,0 +1,103 @@
|
||||
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: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
panel/web/package-lock.json
|
||||
launcher/package-lock.json
|
||||
- name: Install Linux build dependencies
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- 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
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITEA_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build panel container
|
||||
run: |
|
||||
bash scripts/ci/install-linux.sh image
|
||||
docker build -t scopenet-panel:ci .
|
||||
|
||||
launcher-windows:
|
||||
name: Windows launcher
|
||||
runs-on: scopenet
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Windows build dependencies
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
- run: npm run build
|
||||
working-directory: launcher
|
||||
- name: Build NSIS installer
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
run: |
|
||||
npx tauri build --bundles nsis
|
||||
if ($LASTEXITCODE -ne 0) { throw "NSIS build failed with exit code $LASTEXITCODE" }
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer-ci
|
||||
path: target/release/bundle/nsis/*-setup.exe
|
||||
if-no-files-found: error
|
||||
- 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: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Linux build dependencies
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture
|
||||
@@ -0,0 +1,78 @@
|
||||
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: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Check Linux and Java dependencies
|
||||
run: bash scripts/ci/install-linux.sh java 21
|
||||
- name: Build Paper with Gradle 8.10.2
|
||||
run: |
|
||||
sed -i 's/gradle-9.6.0-bin.zip/gradle-8.10.2-bin.zip/' integrations/gradle/wrapper/gradle-wrapper.properties
|
||||
integrations/gradlew -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: ubuntu-latest
|
||||
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: actions/checkout@v5
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.mc.java }}
|
||||
- name: Check Linux and Java dependencies
|
||||
run: bash scripts/ci/install-linux.sh java '${{ matrix.mc.java }}'
|
||||
- name: Build both loaders
|
||||
run: |
|
||||
sed -i 's/gradle-9.6.0-bin.zip/gradle-${{ matrix.mc.gradle }}-bin.zip/' integrations/gradle/wrapper/gradle-wrapper.properties
|
||||
for loader in fabric forge; do
|
||||
integrations/gradlew -p integrations -Ploader="$loader" -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.5.0' }}" :"$loader":build
|
||||
done
|
||||
- name: Audit Fabric startup hooks
|
||||
run: integrations/gradlew -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
|
||||
@@ -0,0 +1,208 @@
|
||||
name: Release
|
||||
|
||||
# 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):
|
||||
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
|
||||
# LOCK_PANEL "1" to stop players from changing the server
|
||||
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g. 0.2.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
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="$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" >> "$GITEA_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITEA_OUTPUT"
|
||||
|
||||
launcher:
|
||||
name: Windows installer
|
||||
needs: version
|
||||
runs-on: scopenet
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Windows build dependencies
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release
|
||||
|
||||
- name: Stamp version
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
$cargo = Get-Content Cargo.toml -Raw
|
||||
$cargo = [regex]::new('(?m)^version = ".*"').Replace($cargo, 'version = "' + $env:VERSION + '"', 1)
|
||||
[IO.File]::WriteAllText((Join-Path (Get-Location) 'Cargo.toml'), $cargo)
|
||||
node -e "for(const f of ['launcher/src-tauri/tauri.conf.json','launcher/package.json']){const fs=require('fs');const c=JSON.parse(fs.readFileSync(f));c.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(c,null,2))}"
|
||||
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
|
||||
- name: Build installer
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
|
||||
run: |
|
||||
if ($env:LAUNCHER_NAME) {
|
||||
$config = node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))"
|
||||
npx tauri build --bundles nsis --config $config
|
||||
} else {
|
||||
npx tauri build --bundles nsis
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Installer build failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Collect installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item target/release/bundle/nsis/*-setup.exe out/
|
||||
Get-ChildItem out
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: out/*-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
panel-image:
|
||||
name: Panel image (Gitea registry)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- 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
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITEA_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Check Linux container dependencies
|
||||
run: bash scripts/ci/install-linux.sh image
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.jadonsandbox.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
|
||||
server-integrations:
|
||||
needs: version
|
||||
uses: ./.gitea/workflows/integrations.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
release:
|
||||
name: Gitea release
|
||||
needs: [version, launcher, panel-image, server-integrations]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Linux release dependencies
|
||||
run: bash scripts/ci/install-linux.sh release
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: server-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release notes
|
||||
run: |
|
||||
IMAGE="${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||
cat > notes.md <<NOTES
|
||||
## Downloads
|
||||
|
||||
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||
|
||||
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
|
||||
|
||||
**Admin panel (Docker):**
|
||||
\`\`\`bash
|
||||
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||
\`\`\`
|
||||
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
|
||||
NOTES
|
||||
- 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,74 +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:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.10.2'
|
||||
- run: gradle -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.4.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: ${{ 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:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.mc.java }}
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: ${{ matrix.mc.gradle }}
|
||||
- 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 (no world startup)
|
||||
if: matrix.loader == 'fabric'
|
||||
run: gradle -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.4.0' }}" -PverifyMixins=true :fabric:runServer
|
||||
- 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
|
||||
@@ -1,180 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`)
|
||||
# or from the Actions tab → Release → Run workflow (enter a version).
|
||||
#
|
||||
# Optional repository variables (Settings → Secrets and variables → Actions → Variables):
|
||||
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
|
||||
# LOCK_PANEL "1" to stop players from changing the server
|
||||
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g. 0.2.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
tag: ${{ steps.v.outputs.tag }}
|
||||
steps:
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.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"
|
||||
|
||||
launcher:
|
||||
name: Windows installer
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: launcher/package-lock.json
|
||||
|
||||
- name: Stamp version
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml
|
||||
node -e "const f='launcher/src-tauri/tauri.conf.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||
node -e "const f='launcher/package.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
|
||||
- name: Build installer
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ github.repository }}
|
||||
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
|
||||
run: |
|
||||
if [ -n "$LAUNCHER_NAME" ]; then
|
||||
CONFIG=$(node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))")
|
||||
npx tauri build --bundles nsis --config "$CONFIG"
|
||||
else
|
||||
npx tauri build --bundles nsis
|
||||
fi
|
||||
|
||||
- name: Collect installer
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p out
|
||||
cp target/release/bundle/nsis/*-setup.exe out/
|
||||
ls -la out
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: out/*-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
panel-image:
|
||||
name: Panel image (GHCR)
|
||||
needs: version
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Image name (GHCR needs lowercase)
|
||||
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_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 }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
server-integrations:
|
||||
needs: version
|
||||
uses: ./.github/workflows/integrations.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
release:
|
||||
name: GitHub release
|
||||
needs: [version, launcher, panel-image, server-integrations]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: server-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release notes
|
||||
run: |
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||
cat > notes.md <<NOTES
|
||||
## Downloads
|
||||
|
||||
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||
|
||||
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
|
||||
|
||||
**Admin panel (Docker):**
|
||||
\`\`\`bash
|
||||
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||
\`\`\`
|
||||
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
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Runner setup
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux image and JAR dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Rust and system packages
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- run: bash scripts/ci/install-linux.sh java 21
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- run: bash scripts/ci/install-linux.sh java 25
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- run: bash scripts/ci/install-linux.sh image
|
||||
|
||||
windows:
|
||||
name: Windows installer dependencies
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install PowerShell, Rust and MSVC
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src/panel/web
|
||||
COPY panel/web/package.json panel/web/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
# The map viewer is shared with the launcher (imported as @scopenet/map).
|
||||
COPY shared/ /src/shared/
|
||||
COPY panel/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -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,21 @@ 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 `ubuntu-latest` label and a Windows host runner with `scopenet:host` (selected by `runs-on: scopenet`). Linux builds the panel Docker image and server JARs; Windows builds the NSIS installer. CI also builds and uploads a Windows NSIS artifact so the installer can be checked without publishing a release. The workflows use [the Linux setup tool](scripts/ci/install-linux.sh) and [the Windows setup tool](scripts/ci/install-windows.ps1) to install or check job dependencies. Linux Node 22 and Java 21/25 are downloaded by `setup-node` and `setup-java`; the Linux tool installs Rust, Tauri system packages, and release utilities. The Windows tool downloads PowerShell 7.6.6 and Node 22 if unavailable, then installs Rust and Microsoft C++ Build Tools. Docker and Buildx are set up by Docker's Actions. The Linux runner must allow a Docker daemon to start or provide one, and system package installation needs root or passwordless sudo. 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/).
|
||||
|
||||
The setup tools also run directly on matching hosts: `bash scripts/ci/install-linux.sh rust` prepares Rust and Tauri builds, `bash scripts/ci/install-linux.sh image` checks Docker, `bash scripts/ci/install-linux.sh java 21` checks a Java integration runner, and `powershell -File scripts/ci/install-windows.ps1` prepares the Windows installer runner. Gitea uses them in CI and release jobs; GitHub provides a manual **Runner setup** workflow for both platforms. Run the Linux `rust` mode as root or with passwordless sudo; the Windows script may need administrator rights to install Microsoft C++ Build Tools.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -92,13 +98,16 @@ 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
|
||||
|
||||
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
||||
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
|
||||
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
|
||||
- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players
|
||||
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
|
||||
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
||||
- [Development](docs/development.md) — running everything locally, tests
|
||||
- [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups
|
||||
|
||||
@@ -7,18 +7,67 @@ use std::path::Path;
|
||||
/// Player-owned vanilla preferences. Pack-managed options.txt may be replaced
|
||||
/// during sync, so these are captured separately and merged back afterward.
|
||||
const VANILLA_KEYS: &[&str] = &[
|
||||
"fov", "gamma", "graphicsMode", "fancyGraphics", "renderDistance", "simulationDistance",
|
||||
"maxFps", "enableVsync", "fullscreen", "fullscreenResolution", "guiScale", "particles",
|
||||
"clouds", "renderClouds", "entityShadows", "entityDistanceScaling", "biomeBlendRadius",
|
||||
"mipmapLevels", "ao", "ambientOcclusion", "bobView", "attackIndicator", "autoJump",
|
||||
"mouseSensitivity", "invertYMouse", "discrete_mouse_scroll", "mouseWheelSensitivity",
|
||||
"rawMouseInput", "toggleCrouch", "toggleSprint", "sneakToggled", "sprintToggled",
|
||||
"screenEffectScale", "fovEffectScale", "darknessEffectScale", "narrator", "subtitles",
|
||||
"chatVisibility", "chatColors", "chatLinks", "chatLinksPrompt", "chatOpacity", "textBackgroundOpacity",
|
||||
"backgroundForChatOnly", "chatScale", "chatWidth", "chatHeightFocused", "chatHeightUnfocused",
|
||||
"mainHand", "lang", "forceUnicodeFont", "tutorialStep", "hideBundleTutorial",
|
||||
"soundDevice", "directionalAudio", "musicFrequency", "notificationDisplayTime",
|
||||
"joinedFirstServer", "pauseOnLostFocus", "reducedDebugInfo", "showSubtitles",
|
||||
"fov",
|
||||
"gamma",
|
||||
"graphicsMode",
|
||||
"fancyGraphics",
|
||||
"renderDistance",
|
||||
"simulationDistance",
|
||||
"maxFps",
|
||||
"enableVsync",
|
||||
"fullscreen",
|
||||
"fullscreenResolution",
|
||||
"guiScale",
|
||||
"particles",
|
||||
"clouds",
|
||||
"renderClouds",
|
||||
"entityShadows",
|
||||
"entityDistanceScaling",
|
||||
"biomeBlendRadius",
|
||||
"mipmapLevels",
|
||||
"ao",
|
||||
"ambientOcclusion",
|
||||
"bobView",
|
||||
"attackIndicator",
|
||||
"autoJump",
|
||||
"mouseSensitivity",
|
||||
"invertYMouse",
|
||||
"discrete_mouse_scroll",
|
||||
"mouseWheelSensitivity",
|
||||
"rawMouseInput",
|
||||
"toggleCrouch",
|
||||
"toggleSprint",
|
||||
"sneakToggled",
|
||||
"sprintToggled",
|
||||
"screenEffectScale",
|
||||
"fovEffectScale",
|
||||
"darknessEffectScale",
|
||||
"narrator",
|
||||
"subtitles",
|
||||
"chatVisibility",
|
||||
"chatColors",
|
||||
"chatLinks",
|
||||
"chatLinksPrompt",
|
||||
"chatOpacity",
|
||||
"textBackgroundOpacity",
|
||||
"backgroundForChatOnly",
|
||||
"chatScale",
|
||||
"chatWidth",
|
||||
"chatHeightFocused",
|
||||
"chatHeightUnfocused",
|
||||
"mainHand",
|
||||
"lang",
|
||||
"forceUnicodeFont",
|
||||
"tutorialStep",
|
||||
"hideBundleTutorial",
|
||||
"soundDevice",
|
||||
"directionalAudio",
|
||||
"musicFrequency",
|
||||
"notificationDisplayTime",
|
||||
"joinedFirstServer",
|
||||
"pauseOnLostFocus",
|
||||
"reducedDebugInfo",
|
||||
"showSubtitles",
|
||||
];
|
||||
|
||||
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
|
||||
@@ -28,10 +77,13 @@ pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, Stri
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(contents.lines().filter_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
|
||||
}).collect())
|
||||
Ok(contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
|
||||
@@ -85,7 +137,11 @@ mod tests {
|
||||
#[test]
|
||||
fn captures_only_vanilla_preferences() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("options.txt"), "fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("options.txt"),
|
||||
"fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n",
|
||||
)
|
||||
.unwrap();
|
||||
let prefs = read_vanilla_preferences(dir.path()).unwrap();
|
||||
assert_eq!(prefs.get("fov").map(String::as_str), Some("0.8"));
|
||||
assert_eq!(prefs.get("autoJump").map(String::as_str), Some("false"));
|
||||
|
||||
@@ -475,7 +475,7 @@ pub struct Quest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub period: String, // "daily", "weekly"
|
||||
pub period: String, // "daily", "weekly"
|
||||
pub category: String, // "mining", "combat", "building", "farming", "social", "exploration"
|
||||
pub target_stat: String,
|
||||
pub target_count: i64,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# BlueMap: the 3D map
|
||||
|
||||
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for the 3D world map. BlueMap runs on each Minecraft server as a plugin or mod and serves the map on its own web address. SCOPENET adds to it, and the launcher shows it inside the app exactly as a browser would.
|
||||
|
||||
## What you get
|
||||
|
||||
| On the map | Where it comes from |
|
||||
|---|---|
|
||||
| **Guild claims** as coloured regions with the guild's icon and name (one colour per guild, outlines follow the real shape, holes included) | the claim index |
|
||||
| **Spawn, warps and guild homes** as pins | the server |
|
||||
| **Homes** as pins (off by default, see privacy) | the server |
|
||||
| **Markets and shops** as pins that list what is for sale | shop points + the panel's market |
|
||||
| **Players** with BlueMap's own live markers; **click one** for a side panel | BlueMap + SCOPENET |
|
||||
|
||||
The player panel shows level, guild, rank, title, playtime and server level. Inside the launcher it also offers **Send teleport request**, **Send message** and **Invite to my guild** (you need to be signed in with a SCOPENET account, and the other player must be online on that server; inviting needs a leader or officer role). In a plain browser it shows the info and points you to the launcher for the actions.
|
||||
|
||||
## Set it up
|
||||
|
||||
1. **Install BlueMap** on the server: the Paper plugin, or the Fabric/Forge mod, from <https://bluemap.bluecolored.de/>. Start the server once. BlueMap asks you to accept a download of the Minecraft client files in its config (`core.conf`: `accept-download: true`), then renders the world.
|
||||
2. **Make its web address reachable.** BlueMap serves on port 8100 by default. Put it behind HTTPS with a reverse proxy (recommended; see below) or open the port.
|
||||
3. **Install the SCOPENET plugin or Fabric mod** (Paper, or Fabric 1.20.1). It finds BlueMap by itself and adds its markers and script. Nothing else to configure.
|
||||
4. **Tell the panel the address:** *Servers → your server → Settings → Map address (BlueMap)*, for example `https://map.example.com`. Players then see a **Live Map** button for that server in the launcher.
|
||||
|
||||
`bluemap.enabled` (Paper `config.yml`, Fabric `scopenet.properties`) turns the addon off. `bluemap.show-homes` / `bluemap.show_homes` shows every player's homes.
|
||||
|
||||
## Shop points (physical shops)
|
||||
|
||||
A shop point is a block that opens the market or the server shop when right-clicked. It works with the normal windows (chest window on Paper; the client mod's shop and market windows where installed) and appears as a pin on the map.
|
||||
|
||||
```
|
||||
/market point add <name> [market|shop] (look at a block; needs scopenet.command.market.point, default op)
|
||||
/market point remove <name>
|
||||
/market point list
|
||||
```
|
||||
|
||||
`market` opens the player market and lists recent listings on the map pin; `shop` opens the server shop.
|
||||
|
||||
## Inside the launcher
|
||||
|
||||
The launcher loads your map address in a frame, so everything BlueMap does in a browser works there too. SCOPENET's script on the map page passes player clicks to the launcher, which shows the side panel. A few things make it work or not:
|
||||
|
||||
* **Use HTTPS.** Loading an `http://` map inside an HTTPS page can be blocked as mixed content on some systems.
|
||||
* **Don't forbid framing.** If your reverse proxy sends `X-Frame-Options: DENY` or a `Content-Security-Policy: frame-ancestors` that excludes the launcher, the map won't load in the app. Remove that header for the map address. The launcher's **Browser** button opens the same map in your browser either way.
|
||||
* The map address is checked by the panel (web address only, no credentials).
|
||||
|
||||
## Privacy
|
||||
|
||||
* The map is public to anyone who knows its address, like any BlueMap. **Homes are off by default** for that reason. Claims and guild names are visible by design.
|
||||
* `players.json`, which drives the browser panel, holds only public facts (name, level, title, guild, rank, playtime). Never balances.
|
||||
* Actions from the map go through the panel, are rate-limited, and need a signed-in account. A message or teleport request reaches only players who are online on that server.
|
||||
|
||||
## Reverse proxy example (nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name map.example.com;
|
||||
listen 443 ssl;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # BlueMap uses server-sent events for live updates
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How it works (for developers)
|
||||
|
||||
* `net.scopenet.core.map` builds the map data without any map software: `ChunkRegions` turns chunks into outlines (tested with an area-conservation check), `MapModel` builds pins and regions and escapes every name, `MapService` gathers it from any thread.
|
||||
* `integrations/bluemap` (`MapAddon`) is the only code that touches BlueMap's API. It copies `scopenet.js`, `scopenet.css` and the icons into BlueMap's web root, registers them, and refreshes the marker sets every 10 seconds.
|
||||
* Map actions are queued in the panel (`server_actions`); the server collects them every 3 seconds (`ActionPoller`) and carries them out with its normal rules. Guild invitations are real invitations (`guild_invites`) that the other player accepts in the launcher or with `/guild accept`.
|
||||
|
||||
## Not verified yet
|
||||
|
||||
The BlueMap addon, the Paper and Fabric wiring, and the launcher's map view have not been compiled or run against a real BlueMap and Minecraft: the build environment cannot download them. The geometry, map model, shop points, action poller and guild-invite commands are unit-tested, and so are the panel endpoints. Build it, and report what BlueMap says in its log and what the map shows.
|
||||
@@ -23,9 +23,8 @@ The panel listens on `PANEL_PORT` (default `8080`). Docker checks
|
||||
`docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
|
||||
blank on first start, the generated password appears in the panel logs.
|
||||
|
||||
To show a 2D world map and claim land in the launcher, install BlueMap on the
|
||||
game server and configure its public web address in the panel. See
|
||||
[Live map setup](live-map.md).
|
||||
Servers draw their own world map and send it to the panel; nothing extra to
|
||||
install or host. See [The SCOPENET Map](map.md).
|
||||
|
||||
The named `panel-data` volume holds the database, uploads, hosted launcher
|
||||
downloads, and the generated JWT signing key. Preserve that volume when
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Discord messages, live embeds and emails
|
||||
|
||||
## Announcements (Admin panel → Discord → Announcements)
|
||||
|
||||
Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it:
|
||||
|
||||
- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields.
|
||||
- The sender's name and picture.
|
||||
- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot.
|
||||
- Player names are cleaned so they can't ping anyone or break the formatting.
|
||||
|
||||
Switch each message on or off, and press **Save & send a sample** to see it in your channel.
|
||||
|
||||
## Live embeds (Discord → Live embeds)
|
||||
|
||||
Four boards that the panel posts once and then **edits in place**, so the channel never fills up:
|
||||
|
||||
| Embed | Shows | Rows are set by |
|
||||
|---|---|---|
|
||||
| **Server status** | Every server: online, players, TPS, version | the line template |
|
||||
| **Active guilds** | Top guilds with level, members, land and leader | rows shown |
|
||||
| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown |
|
||||
| **Richest players** | Top balances of one server economy | economy of, rows shown |
|
||||
|
||||
Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it.
|
||||
|
||||
## Emails (Admin panel → Emails)
|
||||
|
||||
Set up the sender and Resend key under **Settings → Connections** first. Then:
|
||||
|
||||
1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`.
|
||||
2. Check the live **preview**, and **send a test** to yourself.
|
||||
3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons.
|
||||
|
||||
Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account.
|
||||
|
||||
## Live map on the landing page
|
||||
|
||||
In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Live map and land claims
|
||||
|
||||
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for terrain. Install BlueMap on the Minecraft server and make its web app reachable from players' computers. In the panel's **Servers → server settings**, enter the BlueMap **Map address**. The launcher opens the same map in its Live Map window and reads BlueMap's low resolution 2D tile images for the Guilds territory view.
|
||||
|
||||
The terrain address must serve BlueMap's `settings.json`, per-map `settings.json`, and low resolution PNG tiles from the same URL path. A URL such as `https://maps.example.com/` works. If BlueMap is hosted below a path, include the trailing slash, for example `https://example.com/bluemap/`.
|
||||
|
||||
## Center on the player
|
||||
|
||||
Turn on **Live player positions** in the server settings and leave `livemap.enabled: true` in the SCOPENET plugin or mod configuration. The game server sends current positions to the panel. The panel returns each signed-in player only their own position to the launcher. The **Find me** button centers the territory view on it; the blue dot marks their position. Positions expire when the server stops sending updates.
|
||||
|
||||
The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable.
|
||||
|
||||
## Claim controls
|
||||
|
||||
In **Guilds & Territories**, select the game server and dimension. Left click a chunk to claim it for your guild, or drag across chunks to claim an area. Right click or right drag unclaims your guild's chunks. Alt drag pans; the wheel zooms. The overlay shows your guild's claims in green and other guilds' claims in red. The panel checks membership and claim limits for every request; only successful claims appear in the final map state.
|
||||
|
||||
BlueMap must have rendered the area to show terrain. Claims can still be managed by the in-game `/claim` and `/unclaim` commands when the web map is unavailable.
|
||||
|
||||
## Existing world mirror
|
||||
|
||||
Older SCOPENET game integrations uploaded region chunks to the panel under `/api/server/v1/livemap`. The panel retains those ingest endpoints for compatibility with existing servers, but the launcher terrain comes from BlueMap. Current integrations send player positions only.
|
||||
@@ -0,0 +1,48 @@
|
||||
# The SCOPENET Map
|
||||
|
||||
Every server draws its own world map. There is nothing to install, host or configure besides the plugin or mod you already run: the game server renders its world to small tiles in the background, sends only what changed to the panel, and the launcher and admin panel show them in a fast native map.
|
||||
|
||||
What's on it:
|
||||
|
||||
- **Terrain** in the look of a Minecraft map, coloured from the real block textures (modded blocks get a matching colour). The Nether is drawn below its ceiling, so you see caves and lava lakes, not bedrock.
|
||||
- **Guild land** as coloured outlines with the guild's icon and name, holes and all.
|
||||
- **Spawn, warps, guild homes, markets and shop points** as pins you can click.
|
||||
- **Players online**, with their head and facing direction. Click one to open the player panel (profile, teleport request, message in game, invite to your guild).
|
||||
- **Dimensions**: Overworld, Nether and End tabs appear as soon as the server has drawn them.
|
||||
|
||||
## Turn it on
|
||||
|
||||
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 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. 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 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
|
||||
|
||||
Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page).
|
||||
|
||||
## How it works
|
||||
|
||||
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
|
||||
- Tiles are palette PNGs, so a typical tile is a few kilobytes. Only tiles that changed are uploaded, and a region is redrawn at most every 90 seconds while players are building.
|
||||
- The panel stores tiles under its data folder (`map/<server id>/…`) and serves them to signed-in players only, with a short-lived key on every address and ETag caching.
|
||||
- Player positions go up about every two seconds; claims and pins whenever they change.
|
||||
- A local cache lives next to the plugin (`plugins/SCOPENET/map`) or mod (`config/scopenet/map`) so a restart doesn't redraw anything.
|
||||
|
||||
Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the panel. Servers using region files compressed with LZ4 (an opt-in setting in recent Minecraft versions) are not drawn; leave `region-file-compression` on its default.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"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.
|
||||
@@ -12,7 +12,7 @@ Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname:
|
||||
|
||||
Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`.
|
||||
|
||||
The in-app territory view uses the configured BlueMap 2D tile address for each server. It centers on the player's last position when position relay is enabled. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
|
||||
The in-app territory view draws on the server's SCOPENET Map tiles (see [map.md](map.md)). It centers on the player's last position while they are in game. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
|
||||
|
||||
## Warps and item sharing on Paper
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Rewards, limits and permission nodes
|
||||
|
||||
## Rewards beyond XP
|
||||
|
||||
Quests, achievements and rank milestones (level rewards) can hand out more than XP and a title. In the admin panel, open any quest, achievement or milestone and use **Extra rewards**:
|
||||
|
||||
| Reward | What happens | Where it happens |
|
||||
|---|---|---|
|
||||
| **Money** | Credits the balance, once per economy group (or one chosen server) | Panel, instantly |
|
||||
| **Claim chunks** | Raises the land limit of the player's guild while they are a member | Panel, instantly |
|
||||
| **Badge** | Shows a badge on their profile | Panel, instantly |
|
||||
| **Item** | Puts the item in their inventory (anything that doesn't fit drops at their feet). Vanilla ids such as `diamond` or `modid:item` | Game server, when they are online |
|
||||
| **Permission** | Grants a permission node, permanently or for a number of days | Game server (LuckPerms by default) |
|
||||
| **Group** | Adds them to a permission group | Game server (LuckPerms by default) |
|
||||
| **Message** | Sends a chat message | Game server |
|
||||
| **Command** | Runs a console command with `{player}` and `{uuid}` filled in | Game server, only if the server allows it |
|
||||
|
||||
Players see short summaries ("$250.00", "Diamond x3", "+4 claim chunks") next to each quest and achievement in the launcher. Commands are shown only as "A special reward".
|
||||
|
||||
Items, permissions, groups and messages are queued in the panel and handed to the game server once the player is online there. Anything that fails (an unknown item, LuckPerms missing) is retried a few times and then kept in the delivery log with the reason. A reward earned on one server can be delivered by whichever server the player is on, unless you pin it to a server.
|
||||
|
||||
### Server settings
|
||||
|
||||
Paper `config.yml` / Fabric `scopenet.properties`:
|
||||
|
||||
```yaml
|
||||
rewards:
|
||||
enabled: true
|
||||
allow-commands: false # let the panel run its own console commands. Leave off unless every panel admin is trusted.
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
```
|
||||
|
||||
Using another permissions plugin? Change the command templates. Placeholders: `{player}`, `{uuid}`, `{node}`, `{value}`, `{duration}`, `{group}`.
|
||||
|
||||
## What you can configure
|
||||
|
||||
### Per server (plugin/mod config)
|
||||
|
||||
| Setting | Meaning |
|
||||
|---|---|
|
||||
| `essentials.max-homes` | Homes every player can set |
|
||||
| `essentials.home-name-max-length` | Longest home name |
|
||||
| `essentials.max-warps` | Most warps the server may have (0 = no limit) |
|
||||
| `essentials.tpa-timeout-seconds` | How long a `/tpa` request stays open |
|
||||
| `essentials.rtp-radius`, `rtp-min-radius`, `rtp-attempts` | Where `/rtp` sends players, and how hard it tries |
|
||||
| `essentials.cooldowns.<command>` | Seconds between uses of `spawn`, `home`, `back`, `tpa`, `rtp`, `warp` |
|
||||
| `map.enabled`, `map.show-homes` | The SCOPENET Map and whether homes appear on it |
|
||||
| `economy.currency-symbol` | The symbol shown next to money |
|
||||
| `rewards.*` | See above |
|
||||
| Feature switches | `leveling`, `quests`, `achievements`, `guilds`, `social`, `essentials`, `economy` |
|
||||
|
||||
### Network-wide (admin panel → Progression → Economy & guild rules)
|
||||
|
||||
Starting balance, chunks a new guild may claim, extra claims per member and per guild level, guild member limit, and market listings per player. These apply on every server and take effect immediately.
|
||||
|
||||
## Permission nodes that change limits
|
||||
|
||||
Give these with LuckPerms (or any permissions plugin) to ranks:
|
||||
|
||||
| Node | Effect |
|
||||
|---|---|
|
||||
| `scopenet.homes.<number>` | Homes limit for the player, e.g. `scopenet.homes.10`. The highest one a player has wins and never lowers the base `max-homes` |
|
||||
| `scopenet.homes.unlimited` | No home limit |
|
||||
| `scopenet.cooldown.bypass` | Skip every command cooldown |
|
||||
|
||||
Operators and anyone with `*` get the top tier on Paper. On Fabric these nodes are off for everyone until a permissions system (such as the LuckPerms mod) grants them; operators still skip cooldowns.
|
||||
|
||||
Claim chunks are a guild limit, so they are changed with the network rules above or with **Claim chunks** rewards, not with a permission node.
|
||||
@@ -21,15 +21,31 @@ social.chat_prefixes=true
|
||||
essentials.enabled=true
|
||||
economy.enabled=true
|
||||
|
||||
# Send world chunks and player positions to the panel's Live Map
|
||||
# (also needs Live Map switched on for this server in the panel).
|
||||
livemap.enabled=true
|
||||
# Draw this world on the SCOPENET Map: tiles, player positions, guild land and pins go to the panel
|
||||
# (also needs the map switched on for this server in the panel, which it is by default).
|
||||
map.enabled=true
|
||||
|
||||
# --- Essentials (1.20.1) -----------------------------------------------------
|
||||
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
|
||||
# scopenet.homes.25 gives 25 (highest wins), scopenet.homes.unlimited removes the limit.
|
||||
essentials.max_homes=5
|
||||
essentials.rtp_radius=2500
|
||||
essentials.home_name_max_length=32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
essentials.max_warps=0
|
||||
essentials.tpa_timeout_seconds=60
|
||||
# /rtp picks a random safe spot between rtp_min_radius and rtp_radius blocks from 0,0.
|
||||
essentials.rtp_radius=2500
|
||||
essentials.rtp_min_radius=0
|
||||
essentials.rtp_attempts=15
|
||||
# Seconds between uses (0 = none). Starts when the command actually teleports.
|
||||
# Players with scopenet.cooldown.bypass skip all of these.
|
||||
essentials.cooldown.spawn=0
|
||||
essentials.cooldown.home=0
|
||||
essentials.cooldown.back=0
|
||||
essentials.cooldown.tpa=0
|
||||
essentials.cooldown.rtp=0
|
||||
essentials.cooldown.warp=0
|
||||
|
||||
# --- Economy (1.20.1) --------------------------------------------------------
|
||||
# Shown next to amounts.
|
||||
@@ -53,9 +69,15 @@ placeholders.enabled=true
|
||||
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
|
||||
clientlink.enabled=true
|
||||
|
||||
# --- BlueMap (1.20.1) --------------------------------------------------------
|
||||
# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a
|
||||
# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address).
|
||||
bluemap.enabled=true
|
||||
# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link.
|
||||
bluemap.show_homes=false
|
||||
# --- Map pins ----------------------------------------------------------------
|
||||
# Show every player's homes as pins on the map. Off by default: homes are private.
|
||||
map.show_homes=false
|
||||
|
||||
# --- Rewards -----------------------------------------------------------------
|
||||
# Quests, achievements and rank milestones can give items, permissions, groups, messages... (set in the admin panel).
|
||||
rewards.enabled=true
|
||||
# Let the panel run its own console commands as rewards. Leave off unless you trust every panel admin.
|
||||
rewards.allow_commands=false
|
||||
rewards.permission_command=lp user {player} permission set {node} true
|
||||
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
|
||||
rewards.group_command=lp user {player} parent add {group}
|
||||
@@ -58,15 +58,38 @@ social:
|
||||
essentials:
|
||||
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||
enabled: true
|
||||
# Homes per player.
|
||||
# Homes every player can set. Raise it for a rank with a permission node:
|
||||
# scopenet.homes.10 gives 10 homes, scopenet.homes.25 gives 25 (highest wins),
|
||||
# scopenet.homes.unlimited removes the limit. Operators and "*" get the top tier.
|
||||
max-homes: 5
|
||||
# /rtp picks a random safe spot within this many blocks of 0,0.
|
||||
# Longest allowed home name.
|
||||
home-name-max-length: 32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
max-warps: 0
|
||||
# How long a /tpa request stays open.
|
||||
tpa-timeout-seconds: 60
|
||||
# /rtp picks a random safe spot between rtp-min-radius and rtp-radius blocks from 0,0.
|
||||
rtp-radius: 2500
|
||||
rtp-min-radius: 0
|
||||
# Random spots tried before /rtp gives up.
|
||||
rtp-attempts: 15
|
||||
# Seconds between uses (0 = none). Starts when the command actually teleports.
|
||||
# Players with scopenet.cooldown.bypass skip all of these.
|
||||
cooldowns:
|
||||
spawn: 0
|
||||
home: 0
|
||||
back: 0
|
||||
tpa: 0
|
||||
rtp: 0
|
||||
warp: 0
|
||||
|
||||
livemap:
|
||||
# Send live player positions to the panel so the launcher can center its
|
||||
# BlueMap territory view. Configure BlueMap's public address in the panel.
|
||||
map:
|
||||
# The SCOPENET Map: this server draws its world to map tiles in the background and
|
||||
# sends only what changed, with players, guild land and pins, to the panel.
|
||||
# It also needs the map switched on for this server in the panel (on by default).
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private.
|
||||
show-homes: false
|
||||
|
||||
economy:
|
||||
# /balance /pay /baltop /shop /sell /market /orders /trade /transactions
|
||||
@@ -113,15 +136,21 @@ integrations:
|
||||
report-interval-seconds: 30
|
||||
|
||||
# ==============================================================================
|
||||
# BlueMap
|
||||
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
|
||||
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
|
||||
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
|
||||
# Rewards
|
||||
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
|
||||
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
|
||||
# to this server when the player is online. Permissions and groups use console commands, so any permissions
|
||||
# plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
|
||||
# ==============================================================================
|
||||
bluemap:
|
||||
rewards:
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
|
||||
show-homes: false
|
||||
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the
|
||||
# panel could otherwise run commands on this server. Only switch it on if you trust every panel admin.
|
||||
allow-commands: false
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-deny-command: "lp user {player} permission set {node} false"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
|
||||
# ==============================================================================
|
||||
# Client mod link
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
|
||||
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
|
||||
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
|
||||
repositories {
|
||||
maven { url = 'https://repo.bluecolored.de/releases' }
|
||||
}
|
||||
configurations.compileClasspath {
|
||||
attributes {
|
||||
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package net.scopenet.bluemap;
|
||||
|
||||
import com.flowpowered.math.vector.Vector2d;
|
||||
import com.flowpowered.math.vector.Vector3d;
|
||||
import com.google.gson.JsonObject;
|
||||
import de.bluecolored.bluemap.api.BlueMapAPI;
|
||||
import de.bluecolored.bluemap.api.BlueMapMap;
|
||||
import de.bluecolored.bluemap.api.markers.Marker;
|
||||
import de.bluecolored.bluemap.api.markers.MarkerSet;
|
||||
import de.bluecolored.bluemap.api.markers.POIMarker;
|
||||
import de.bluecolored.bluemap.api.markers.ShapeMarker;
|
||||
import de.bluecolored.bluemap.api.math.Color;
|
||||
import de.bluecolored.bluemap.api.math.Shape;
|
||||
import net.scopenet.core.map.ChunkRegions;
|
||||
import net.scopenet.core.map.MapData;
|
||||
import net.scopenet.core.map.MapModel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Puts SCOPENET on the BlueMap 3D map:
|
||||
* <ul>
|
||||
* <li>guild land as coloured regions, with the guild's icon and name,</li>
|
||||
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
|
||||
* <li>markets and shops as pins that list what is for sale,</li>
|
||||
* <li>a script that opens a side panel when you click a player.</li>
|
||||
* </ul>
|
||||
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
|
||||
* nothing if BlueMap isn't installed.
|
||||
*/
|
||||
public final class MapAddon {
|
||||
/** Where the addon gets its data. Both calls must be safe from any thread. */
|
||||
public interface Feed {
|
||||
MapData snapshot();
|
||||
JsonObject playersJson();
|
||||
}
|
||||
|
||||
static final String WEB_DIR = "assets/scopenet";
|
||||
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
|
||||
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
|
||||
static final float CLAIM_Y = 66f;
|
||||
|
||||
private final Feed feed;
|
||||
private final Logger log;
|
||||
private ScheduledExecutorService worker;
|
||||
private volatile boolean enabled;
|
||||
|
||||
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
|
||||
|
||||
/** Hook into BlueMap. Returns a handle to stop it. */
|
||||
public static MapAddon start(Feed feed, Logger log) {
|
||||
MapAddon addon = new MapAddon(feed, log);
|
||||
BlueMapAPI.onEnable(addon::enable);
|
||||
BlueMapAPI.onDisable(api -> addon.disable());
|
||||
return addon;
|
||||
}
|
||||
|
||||
public void stop() { disable(); }
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------------------------
|
||||
|
||||
private synchronized void enable(BlueMapAPI api) {
|
||||
try {
|
||||
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
|
||||
Files.createDirectories(root);
|
||||
for (String name : ASSETS) copy(name, root.resolve(name));
|
||||
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
|
||||
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warning("SCOPENET could not add its files to BlueMap: " + e);
|
||||
return;
|
||||
}
|
||||
enabled = true;
|
||||
worker = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "scopenet-bluemap");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
|
||||
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
|
||||
}
|
||||
|
||||
private synchronized void disable() {
|
||||
enabled = false;
|
||||
if (worker != null) worker.shutdownNow();
|
||||
worker = null;
|
||||
}
|
||||
|
||||
private static void copy(String name, Path target) throws IOException {
|
||||
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
|
||||
if (in == null) throw new IOException("missing resource " + name);
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- refresh ---------------------------------------------------------------------------
|
||||
|
||||
private void refresh(BlueMapAPI api) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
MapData data = feed.snapshot();
|
||||
writePlayers(api);
|
||||
for (BlueMapMap map : api.getMaps()) update(map, data);
|
||||
} catch (RuntimeException | IOException e) {
|
||||
log.fine("SCOPENET map refresh failed: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private void writePlayers(BlueMapAPI api) throws IOException {
|
||||
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
|
||||
Path tmp = file.resolveSibling("players.json.tmp");
|
||||
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
private void update(BlueMapMap map, MapData data) {
|
||||
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
|
||||
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
|
||||
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
|
||||
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
|
||||
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
|
||||
}
|
||||
|
||||
/** Replace a marker set's contents in place, so the map doesn't flicker. */
|
||||
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
|
||||
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
|
||||
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
|
||||
wanted.forEach(set::put);
|
||||
}
|
||||
|
||||
// ---- markers ---------------------------------------------------------------------------
|
||||
|
||||
private static Shape shape(ChunkRegions.Ring ring) {
|
||||
List<Vector2d> points = new ArrayList<>();
|
||||
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
|
||||
return new Shape(points);
|
||||
}
|
||||
|
||||
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
|
||||
Map<String, Marker> out = new LinkedHashMap<>();
|
||||
for (MapData.ClaimRegion c : data.claims()) {
|
||||
if (!c.dimension().equals(dimension)) continue;
|
||||
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
|
||||
ShapeMarker marker = ShapeMarker.builder()
|
||||
.label(c.name())
|
||||
.shape(shape(c.outer()), CLAIM_Y)
|
||||
.holes(holes)
|
||||
.lineColor(new Color(c.color(), 0.95f))
|
||||
.fillColor(new Color(c.color(), 0.28f))
|
||||
.lineWidth(3)
|
||||
.depthTestEnabled(false)
|
||||
.detail(c.detail())
|
||||
.build();
|
||||
out.put("claim-" + c.id(), marker);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One icon and name per area, in the middle of it, fading out when you are far away. */
|
||||
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
|
||||
Map<String, Marker> out = new LinkedHashMap<>();
|
||||
for (MapData.ClaimRegion c : data.claims()) {
|
||||
if (!c.dimension().equals(dimension)) continue;
|
||||
POIMarker.Builder builder = POIMarker.builder()
|
||||
.label(c.name() + " [" + c.tag() + "]")
|
||||
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
|
||||
.styleClasses("scopenet-guild-icon")
|
||||
.maxDistance(1600)
|
||||
.detail(c.detail());
|
||||
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
|
||||
out.put("guild-" + c.id(), builder.build());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
|
||||
Map<String, Marker> out = new LinkedHashMap<>();
|
||||
for (MapData.Pin p : data.pins()) {
|
||||
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
|
||||
if (!p.dimension().equals(dimension) || isShop != shops) continue;
|
||||
String icon = switch (p.kind()) {
|
||||
case "spawn" -> "spawn.svg";
|
||||
case "warp" -> "warp.svg";
|
||||
case "home" -> "home.svg";
|
||||
case "guild_home" -> "guild-home.svg";
|
||||
case "market" -> "market.svg";
|
||||
default -> "shop.svg";
|
||||
};
|
||||
out.put(p.id(), POIMarker.builder()
|
||||
.label(p.label())
|
||||
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
|
||||
.icon(WEB_DIR + "/" + icon, 16, 16)
|
||||
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
|
||||
.maxDistance(2400)
|
||||
.detail(p.detail())
|
||||
.build());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 243 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 3l11 4v8c0 7-4.6 12-11 14C9.6 27 5 22 5 15V7z" fill="#8b6cff" stroke="#fff" stroke-width="2"/><path d="M16 9l2.2 4.6 5 .7-3.6 3.5.9 5L16 20.4 11.5 22.8l.9-5-3.6-3.5 5-.7z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 288 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fbbf24" stroke="#ffffff" stroke-width="2"/><path d="M16 7l9 8h-3v8h-4v-5h-4v5h-4v-8H7z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 229 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fb7185" stroke="#ffffff" stroke-width="2"/><path d="M7 12l2-5h14l2 5v2c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3zM9 19h14v6H9z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1,64 +0,0 @@
|
||||
/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */
|
||||
|
||||
/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */
|
||||
.scopenet-guild-icon img,
|
||||
.bm-marker-poi.scopenet-guild-icon img {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
object-fit: cover;
|
||||
border-radius: 9px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
|
||||
background: #1a1d25;
|
||||
}
|
||||
.scopenet-pin img,
|
||||
.bm-marker-poi.scopenet-pin img {
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
/* Popup text inside marker details. */
|
||||
.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; }
|
||||
.scopenet-detail span { opacity: 0.7; }
|
||||
.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; }
|
||||
.scopenet-list { margin: 4px 0 0; padding-left: 16px; }
|
||||
|
||||
/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */
|
||||
#scopenet-panel {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
right: 14px;
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 90px);
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(20, 22, 30, 0.96);
|
||||
color: #e6e8ee;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
animation: scopenet-in 0.18s ease;
|
||||
}
|
||||
@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } }
|
||||
#scopenet-panel button.close {
|
||||
position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px;
|
||||
background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer;
|
||||
}
|
||||
#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
|
||||
#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; }
|
||||
#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; }
|
||||
#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; }
|
||||
#scopenet-panel .title { color: #fbbf24; font-size: 12px; }
|
||||
#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
|
||||
#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; }
|
||||
#scopenet-panel .chip.lv { color: #8b6cff; }
|
||||
#scopenet-panel .chip.guild { color: #22d3ee; }
|
||||
#scopenet-panel .chip.rank { color: #fbbf24; }
|
||||
#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; }
|
||||
#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; }
|
||||
#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; }
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and
|
||||
* inside the SCOPENET launcher.
|
||||
*
|
||||
* - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the
|
||||
* server keeps up to date next to this file.
|
||||
* - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel
|
||||
* with the actions that need a signed-in account: teleport request, message, guild invite.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var BASE = 'assets/scopenet/';
|
||||
var PLAYER_PREFIX = 'bm-marker-bm-player-';
|
||||
var embedded = window.parent && window.parent !== window;
|
||||
var players = {};
|
||||
var selected = null;
|
||||
|
||||
function esc(text) {
|
||||
return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-<uuid>". */
|
||||
function uuidFromId(id) {
|
||||
return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** Which player marker is under this screen point? Works even where the marker ignores pointer events. */
|
||||
function playerAt(x, y) {
|
||||
var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]');
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var r = nodes[i].getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hours(seconds) {
|
||||
seconds = Number(seconds) || 0;
|
||||
return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm';
|
||||
}
|
||||
|
||||
function panel() {
|
||||
var el = document.getElementById('scopenet-panel');
|
||||
if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); }
|
||||
return el;
|
||||
}
|
||||
|
||||
function render() {
|
||||
var el = document.getElementById('scopenet-panel');
|
||||
if (!selected) { if (el) el.remove(); return; }
|
||||
var p = players[selected] || players[selected.toLowerCase()] || {};
|
||||
el = panel();
|
||||
var name = p.name || 'Player';
|
||||
el.innerHTML =
|
||||
'<button class="close" aria-label="Close">×</button>' +
|
||||
'<div class="head"><img alt="" src="assets/playerheads/' + esc(selected) + '.png" onerror="this.src=\'assets/steve.png\'">' +
|
||||
'<div><h3>' + esc(name) + '</h3>' + (p.title ? '<div class="title">' + esc(p.title) + '</div>' : '') + '</div></div>' +
|
||||
'<div class="chips">' +
|
||||
'<span class="chip lv">Level ' + esc(p.level || 1) + '</span>' +
|
||||
(p.guild ? '<span class="chip guild">[' + esc(p.guildTag) + '] ' + esc(p.guild) + '</span>' : '') +
|
||||
(p.rank ? '<span class="chip rank">' + esc(p.rank) + '</span>' : '') + '</div>' +
|
||||
'<div class="facts">' +
|
||||
'<div>Playtime<strong>' + esc(hours(p.playtime)) + '</strong></div>' +
|
||||
'<div>Server level<strong>' + esc(p.serverLevel || 1) + '</strong></div>' +
|
||||
(p.guildRole ? '<div>Guild role<strong>' + esc(p.guildRole) + '</strong></div>' : '') + '</div>' +
|
||||
'<p class="note">Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.</p>';
|
||||
el.querySelector('.close').addEventListener('click', function () { select(null); });
|
||||
}
|
||||
|
||||
function select(uuid) {
|
||||
selected = uuid;
|
||||
if (embedded) {
|
||||
window.parent.postMessage(uuid
|
||||
? { source: 'scopenet-map', type: 'player', uuid: uuid, name: (players[uuid] || {}).name || '' }
|
||||
: { source: 'scopenet-map', type: 'deselect' }, '*');
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetch(BASE + 'players.json?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { if (j && j.players) { players = j.players; if (!embedded && selected) render(); } })
|
||||
.catch(function () { /* the file appears once the server has written it */ });
|
||||
}
|
||||
|
||||
// A click is a press and release in nearly the same place; a drag moves the map.
|
||||
var down = null;
|
||||
document.addEventListener('pointerdown', function (e) { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true);
|
||||
document.addEventListener('pointerup', function (e) {
|
||||
var start = down;
|
||||
down = null;
|
||||
if (!start || e.button > 0) return;
|
||||
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6 || Date.now() - start.t > 700) return;
|
||||
if (e.target && e.target.closest && e.target.closest('#scopenet-panel')) return;
|
||||
var uuid = playerAt(e.clientX, e.clientY);
|
||||
if (uuid) select(uuid);
|
||||
else if (selected) select(null);
|
||||
}, true);
|
||||
|
||||
window.addEventListener('message', function (e) {
|
||||
if (e.source === window.parent && e.data && e.data.source === 'scopenet-launcher' && e.data.type === 'hello') { /* acknowledged */ }
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
if (embedded) window.parent.postMessage({ source: 'scopenet-map', type: 'ready' }, '*');
|
||||
|
||||
// Exposed for tests.
|
||||
window.__scopenet = { uuidFromId: uuidFromId, esc: esc, hours: hours };
|
||||
})();
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#f97316" stroke="#ffffff" stroke-width="2"/><path d="M8 10h3l2 10h10l2-7H12" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/><circle cx="14" cy="24" r="1.8" fill="#fff"/><circle cx="22" cy="24" r="1.8" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 387 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#34d399" stroke="#ffffff" stroke-width="2"/><path d="M16 7l2.6 6.4 6.9.5-5.3 4.5 1.7 6.7L16 21.3 10.1 25l1.7-6.7-5.3-4.5 6.9-.5z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 270 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#8b6cff" stroke="#ffffff" stroke-width="2"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="2" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 290 B |
@@ -12,6 +12,7 @@ public final class CommandSet {
|
||||
private final GuildCommands guildCommands;
|
||||
private final EconomyCommands economyCommands;
|
||||
private final List<CoreCommand> commands = new ArrayList<>();
|
||||
private final Limits.Cooldowns cooldowns = new Limits.Cooldowns();
|
||||
|
||||
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
|
||||
Path dir = env.dataDir;
|
||||
@@ -22,7 +23,8 @@ public final class CommandSet {
|
||||
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
|
||||
economyCommands = economy;
|
||||
guildCommands = guilds;
|
||||
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
|
||||
add(env, EssentialsCommands.build(env, essentials).stream().<CoreCommand>map(c -> new Cooled(c, essentials, cooldowns, env.clock)).toList(),
|
||||
"Essentials", () -> env.features.essentials());
|
||||
add(env, economy.build(), "Economy", () -> env.features.economy());
|
||||
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
|
||||
}
|
||||
@@ -55,6 +57,29 @@ public final class CommandSet {
|
||||
/** Call about once a second from the server thread. */
|
||||
public void tick() { jobs.tick(); }
|
||||
|
||||
/**
|
||||
* Teleport commands can have a cooldown ({@code essentials.cooldowns}). It starts when the command actually teleports (or sends a
|
||||
* request), so a typo doesn't cost the player anything. Players with {@code scopenet.cooldown.bypass} skip it.
|
||||
*/
|
||||
private record Cooled(CoreCommand inner, EssentialsService essentials, Limits.Cooldowns cooldowns, java.util.function.LongSupplier clock) implements CoreCommand {
|
||||
@Override public String name() { return inner.name(); }
|
||||
@Override public List<String> aliases() { return inner.aliases(); }
|
||||
@Override public String permission() { return inner.permission(); }
|
||||
@Override public void run(CorePlayer p, String[] args) {
|
||||
int seconds = essentials.cooldownSeconds(inner.name());
|
||||
boolean cooled = seconds > 0 && !p.hasPermission("scopenet.cooldown.bypass");
|
||||
long now = clock.getAsLong();
|
||||
if (cooled) {
|
||||
long wait = cooldowns.remainingMs(p.uuid(), inner.name(), now);
|
||||
if (wait > 0) { p.send(Format.RED + "Please wait " + Limits.Cooldowns.waitText(wait) + " before using /" + inner.name() + " again."); return; }
|
||||
}
|
||||
long before = essentials.lastAction(p.uuid());
|
||||
inner.run(p, args);
|
||||
if (cooled && essentials.lastAction(p.uuid()) != before) cooldowns.start(p.uuid(), inner.name(), now, seconds);
|
||||
}
|
||||
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
|
||||
}
|
||||
|
||||
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */
|
||||
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
|
||||
@Override public String name() { return inner.name(); }
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every tunable of homes, warps, TPA and RTP. Players can exceed {@code maxHomes} with a permission node
|
||||
* ({@code scopenet.homes.<number>}, or {@code scopenet.homes.unlimited}); {@code cooldowns} are seconds per command
|
||||
* (0 = none) and are skipped for anyone with {@code scopenet.cooldown.bypass}.
|
||||
*/
|
||||
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts, int rtpMinRadius, int maxWarps,
|
||||
int homeNameMax, Map<String, Integer> cooldowns) {
|
||||
public static final int TIER_CAP = 500;
|
||||
|
||||
public EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
|
||||
this(maxHomes, rtpRadius, tpaTimeoutMs, rtpAttempts, 0, 0, 32, Map.of());
|
||||
}
|
||||
|
||||
public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
|
||||
|
||||
public int cooldown(String command) { return cooldowns == null ? 0 : cooldowns.getOrDefault(command, 0); }
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public final class EssentialsService {
|
||||
|
||||
public void configure(EssentialsConfig next) { this.config = next; }
|
||||
|
||||
public int cooldownSeconds(String command) { return config.cooldown(command); }
|
||||
|
||||
// ---- persistence -------------------------------------------------------
|
||||
|
||||
private void load() {
|
||||
@@ -113,8 +115,10 @@ public final class EssentialsService {
|
||||
public void setHome(CorePlayer p, String[] args) {
|
||||
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
|
||||
Map<String, Pos> mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>());
|
||||
if (!mine.containsKey(name) && mine.size() >= config.maxHomes()) {
|
||||
p.send(RED + "You have reached your limit of " + config.maxHomes() + " homes.");
|
||||
if (name.length() > config.homeNameMax()) { p.send(RED + "Home names can be at most " + config.homeNameMax() + " characters."); return; }
|
||||
int limit = Limits.tier(p::hasPermission, "scopenet.homes.", config.maxHomes(), EssentialsConfig.TIER_CAP);
|
||||
if (!mine.containsKey(name) && mine.size() >= limit) {
|
||||
p.send(RED + "You have reached your limit of " + Limits.show(limit) + " homes.");
|
||||
return;
|
||||
}
|
||||
mine.put(name, p.pos());
|
||||
@@ -161,6 +165,10 @@ public final class EssentialsService {
|
||||
}
|
||||
|
||||
public void setWarp(CorePlayer p, String name) {
|
||||
if (config.maxWarps() > 0 && !warps.containsKey(name.toLowerCase(Locale.ROOT)) && warps.size() >= config.maxWarps()) {
|
||||
p.send(RED + "The server already has " + config.maxWarps() + " warps. Delete one first.");
|
||||
return;
|
||||
}
|
||||
warps.put(name.toLowerCase(Locale.ROOT), p.pos());
|
||||
save();
|
||||
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set.");
|
||||
@@ -190,6 +198,7 @@ public final class EssentialsService {
|
||||
CorePlayer target = found.get();
|
||||
if (target.uuid().equals(p.uuid())) { p.send(RED + "You cannot teleport to yourself."); return; }
|
||||
pendingTpa.put(target.uuid(), Map.entry(p.uuid(), clock.getAsLong()));
|
||||
lastAction.put(p.uuid(), clock.getAsLong());
|
||||
p.send(GREEN + "Teleport request sent to " + YELLOW + target.name() + GREEN + ".");
|
||||
target.send(YELLOW + p.name() + GREEN + " has sent you a teleport request.");
|
||||
target.send(GRAY + "Type " + YELLOW + "/tpaccept" + GRAY + " to accept or " + RED + "/tpdeny" + GRAY
|
||||
@@ -223,11 +232,13 @@ public final class EssentialsService {
|
||||
/** Must run on the server thread, because it reads chunks. */
|
||||
public void rtp(CorePlayer p) {
|
||||
int radius = config.rtpRadius();
|
||||
int min = Math.min(config.rtpMinRadius(), radius - 1);
|
||||
String world = p.pos().world();
|
||||
p.send(GRAY + "Searching for a safe wilderness destination...");
|
||||
for (int i = 0; i < config.rtpAttempts(); i++) {
|
||||
int x = rng.nextInt(radius * 2) - radius;
|
||||
int z = rng.nextInt(radius * 2) - radius;
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < min) continue; // too close to the centre
|
||||
Optional<Pos> spot = platform.safeSurface(world, x, z);
|
||||
if (spot.isPresent()) {
|
||||
Pos at = spot.get();
|
||||
@@ -242,7 +253,13 @@ public final class EssentialsService {
|
||||
/** Teleport and remember where they were for /back. */
|
||||
public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); }
|
||||
|
||||
/** When a player last teleported or sent a teleport request (for cooldowns). */
|
||||
public long lastAction(UUID id) { return lastAction.getOrDefault(id, -1L); }
|
||||
|
||||
private final Map<UUID, Long> lastAction = new ConcurrentHashMap<>();
|
||||
|
||||
private void go(CorePlayer p, Pos destination) {
|
||||
lastAction.put(p.uuid(), clock.getAsLong());
|
||||
remember(p.uuid(), p.pos());
|
||||
p.teleport(destination);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Per-player limits that permission nodes can raise, and command cooldowns. */
|
||||
public final class Limits {
|
||||
private Limits() {}
|
||||
|
||||
/**
|
||||
* The highest tier a player has, e.g. {@code scopenet.homes.10}, otherwise {@code base}. {@code <prefix>unlimited}
|
||||
* means no limit. Only values above {@code base} are looked for, up to {@code cap}.
|
||||
*/
|
||||
public static int tier(Predicate<String> has, String prefix, int base, int cap) {
|
||||
if (has.test(prefix + "unlimited")) return Integer.MAX_VALUE;
|
||||
for (int n = cap; n > base; n--) if (has.test(prefix + n)) return n;
|
||||
return base;
|
||||
}
|
||||
|
||||
public static String show(int limit) { return limit == Integer.MAX_VALUE ? "unlimited" : Integer.toString(limit); }
|
||||
|
||||
/** When each player may use each command again. Not thread-safe: use from the server thread. */
|
||||
public static final class Cooldowns {
|
||||
private final Map<String, Long> until = new HashMap<>();
|
||||
|
||||
public long remainingMs(UUID player, String command, long now) {
|
||||
Long end = until.get(player + ":" + command);
|
||||
return end == null ? 0 : Math.max(0, end - now);
|
||||
}
|
||||
|
||||
public void start(UUID player, String command, long now, int seconds) {
|
||||
if (seconds > 0) until.put(player + ":" + command, now + seconds * 1000L);
|
||||
if (until.size() > 4000) until.values().removeIf(end -> end < now);
|
||||
}
|
||||
|
||||
public static String waitText(long ms) { return Math.max(1, (ms + 999) / 1000) + "s"; }
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,10 @@ public interface Platform {
|
||||
void runMain(Runnable task);
|
||||
/** Run off the server thread, for network calls. */
|
||||
void runAsync(Runnable task);
|
||||
|
||||
/** Put items in the player's inventory (anything that doesn't fit drops at their feet). False if the item doesn't exist here. */
|
||||
default boolean giveItem(UUID player, String itemId, int amount) { return false; }
|
||||
|
||||
/** Run a command as the console; true if it was found and run. Called on the server thread. */
|
||||
default boolean console(String command) { return false; }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Collects the rewards the panel owes to players on this server (items, permissions, groups, commands, messages) and carries
|
||||
* them out, then tells the panel what worked. The panel only hands rewards over for players who are online here.
|
||||
*/
|
||||
public final class RewardPoller {
|
||||
public static final long EVERY_MS = 5_000;
|
||||
|
||||
private final Env env;
|
||||
private volatile RewardRules rules;
|
||||
private long next;
|
||||
private volatile boolean running;
|
||||
|
||||
public RewardPoller(Env env, RewardRules rules) { this.env = env; this.rules = rules; }
|
||||
|
||||
public void rules(RewardRules rules) { this.rules = rules; }
|
||||
|
||||
/** Call often on the server thread; it asks the panel at most every few seconds. */
|
||||
public void tick() {
|
||||
if (!rules.enabled()) return;
|
||||
long now = env.clock.getAsLong();
|
||||
if (running || now < next) return;
|
||||
next = now + EVERY_MS;
|
||||
running = true;
|
||||
env.platform.runAsync(() -> {
|
||||
try {
|
||||
JsonElement answer = env.panel.call("rewards/poll", new JsonObject());
|
||||
JsonArray list = answer.isJsonObject() && answer.getAsJsonObject().has("deliveries") ? answer.getAsJsonObject().getAsJsonArray("deliveries") : new JsonArray();
|
||||
if (list.size() > 0) env.platform.runMain(() -> carryOut(list));
|
||||
} catch (Exception e) {
|
||||
// The panel is briefly unreachable: try again on the next round.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; }
|
||||
|
||||
/** Server thread. Applies each delivery, then reports back from the background. */
|
||||
void carryOut(JsonArray list) {
|
||||
JsonArray done = new JsonArray(), failed = new JsonArray();
|
||||
for (JsonElement el : list) {
|
||||
JsonObject d = el.getAsJsonObject();
|
||||
long id = d.get("id").getAsLong();
|
||||
String error;
|
||||
try {
|
||||
error = apply(d);
|
||||
} catch (RuntimeException e) {
|
||||
error = e.getClass().getSimpleName();
|
||||
}
|
||||
if (error == null) done.add(id);
|
||||
else {
|
||||
JsonObject f = new JsonObject();
|
||||
f.addProperty("id", id);
|
||||
f.addProperty("error", error);
|
||||
failed.add(f);
|
||||
}
|
||||
}
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("done", done);
|
||||
body.add("failed", failed);
|
||||
env.platform.runAsync(() -> {
|
||||
try { env.panel.call("rewards/ack", body); } catch (Exception e) { /* the panel re-offers it, and a second try is harmless */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Null when carried out, otherwise why not. */
|
||||
String apply(JsonObject d) {
|
||||
UUID uuid;
|
||||
try { uuid = UUID.fromString(text(d, "uuid")); } catch (IllegalArgumentException e) { return "bad player id"; }
|
||||
Optional<CorePlayer> player = env.platform.player(uuid);
|
||||
if (player.isEmpty()) return "player left"; // retried when they are back
|
||||
JsonObject a = d.has("payload") && d.get("payload").isJsonObject() ? d.getAsJsonObject("payload") : new JsonObject();
|
||||
String name = player.get().name();
|
||||
RewardRules r = rules;
|
||||
switch (text(d, "kind")) {
|
||||
case "item" -> {
|
||||
int amount = a.has("amount") ? a.get("amount").getAsInt() : 1;
|
||||
if (!env.platform.giveItem(uuid, text(a, "item"), amount)) return "unknown item " + text(a, "item");
|
||||
player.get().send(Format.GREEN + "You received " + Format.WHITE + amount + "x " + prettyItem(text(a, "item")) + Format.GREEN + "!");
|
||||
}
|
||||
case "permission" -> {
|
||||
boolean value = !a.has("value") || a.get("value").getAsBoolean();
|
||||
long minutes = a.has("minutes") ? a.get("minutes").getAsLong() : 0;
|
||||
String template = minutes > 0 ? r.permissionTempCommand() : value ? r.permissionCommand() : r.permissionDenyCommand();
|
||||
String command = template.replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{node}", text(a, "node"))
|
||||
.replace("{value}", Boolean.toString(value)).replace("{duration}", minutes + "m");
|
||||
if (!env.platform.console(command)) return "the permissions command did not run (is LuckPerms installed?)";
|
||||
player.get().send(Format.GREEN + "You gained a new permission: " + Format.WHITE + text(a, "node") + Format.GREEN + ".");
|
||||
}
|
||||
case "group" -> {
|
||||
String command = r.groupCommand().replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{group}", text(a, "group"));
|
||||
if (!env.platform.console(command)) return "the group command did not run (is LuckPerms installed?)";
|
||||
player.get().send(Format.GREEN + "You joined the " + Format.WHITE + text(a, "group") + Format.GREEN + " group.");
|
||||
}
|
||||
case "command" -> {
|
||||
if (!r.allowCommands()) return "custom commands are switched off on this server (rewards.allow-commands)";
|
||||
String command = text(a, "command").replace("{player}", name).replace("{uuid}", uuid.toString());
|
||||
if (!env.platform.console(command)) return "command not found";
|
||||
}
|
||||
case "message" -> player.get().send(Format.GOLD + text(a, "text"));
|
||||
default -> { return "unknown reward kind"; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String prettyItem(String id) {
|
||||
String n = id.contains(":") ? id.substring(id.indexOf(':') + 1) : id;
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (String w : n.split("[_/]")) if (!w.isEmpty()) out.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1)).append(' ');
|
||||
return out.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
/**
|
||||
* How this server carries out rewards the panel hands over. Permissions and groups go through console commands so any
|
||||
* permissions plugin works; the defaults are LuckPerms'. Custom admin commands stay off unless the server owner allows them.
|
||||
*/
|
||||
public record RewardRules(boolean enabled, boolean allowCommands, String permissionCommand, String permissionDenyCommand,
|
||||
String permissionTempCommand, String groupCommand) {
|
||||
public static RewardRules defaults() {
|
||||
return new RewardRules(true, false, "lp user {player} permission set {node} true", "lp user {player} permission set {node} false",
|
||||
"lp user {player} permission settemp {node} {value} {duration}", "lp user {player} parent add {group}");
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ public final class MapModel {
|
||||
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
|
||||
}
|
||||
|
||||
/** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
|
||||
/** Which Minecraft dimension a world id or name means ("world_nether" -> the nether). */
|
||||
public static String dimensionOf(String id, String name) {
|
||||
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
|
||||
if (words.contains("nether")) return "minecraft:the_nether";
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text
|
||||
* lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup.
|
||||
*/
|
||||
public final class MapOverlay {
|
||||
private MapOverlay() {}
|
||||
|
||||
public static JsonObject toJson(MapData data) {
|
||||
JsonArray claims = new JsonArray();
|
||||
for (MapData.ClaimRegion c : data.claims()) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", c.id());
|
||||
o.addProperty("guild_id", c.guildId());
|
||||
o.addProperty("name", c.name());
|
||||
o.addProperty("tag", c.tag());
|
||||
o.addProperty("icon", c.icon());
|
||||
o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF));
|
||||
o.addProperty("dimension", c.dimension());
|
||||
o.addProperty("chunks", c.chunks());
|
||||
o.addProperty("label_x", c.labelX());
|
||||
o.addProperty("label_z", c.labelZ());
|
||||
o.add("outer", ring(c.outer()));
|
||||
JsonArray holes = new JsonArray();
|
||||
for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole));
|
||||
o.add("holes", holes);
|
||||
o.add("lines", lines(c.detail()));
|
||||
claims.add(o);
|
||||
}
|
||||
JsonArray pins = new JsonArray();
|
||||
for (MapData.Pin p : data.pins()) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", p.id());
|
||||
o.addProperty("kind", p.kind());
|
||||
o.addProperty("label", p.label());
|
||||
o.addProperty("dimension", p.dimension());
|
||||
o.addProperty("x", p.x());
|
||||
o.addProperty("y", p.y());
|
||||
o.addProperty("z", p.z());
|
||||
o.add("lines", lines(p.detail()));
|
||||
pins.add(o);
|
||||
}
|
||||
JsonObject root = new JsonObject();
|
||||
root.add("claims", claims);
|
||||
root.add("pins", pins);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JsonArray ring(ChunkRegions.Ring ring) {
|
||||
JsonArray out = new JsonArray();
|
||||
for (ChunkRegions.Point p : ring.points()) {
|
||||
JsonArray pair = new JsonArray();
|
||||
pair.add(p.x());
|
||||
pair.add(p.z());
|
||||
out.add(pair);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */
|
||||
static JsonArray lines(String html) {
|
||||
String text = html == null ? "" : html
|
||||
.replaceAll("(?i)<br\\s*/?>|</div>|</li>|</ul>|<ul[^>]*>", "\n")
|
||||
.replaceAll("<[^>]*>", "");
|
||||
text = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.replace("·", "·").replace("&", "&");
|
||||
JsonArray out = new JsonArray();
|
||||
for (String line : text.split("\n")) {
|
||||
String t = line.trim();
|
||||
if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import net.scopenet.integration.ClaimIndex;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every
|
||||
* Gathers everything the map needs, from any thread. The map overlay asks for a {@link MapData} every
|
||||
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
|
||||
*/
|
||||
public final class MapService {
|
||||
|
||||
@@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable {
|
||||
private final AtomicBoolean syncing = new AtomicBoolean();
|
||||
private volatile boolean closed;
|
||||
private volatile Consumer<JsonArray> notificationHandler;
|
||||
private volatile LiveMapSync liveMap;
|
||||
private volatile net.scopenet.worldmap.WorldMapSync map;
|
||||
private volatile java.util.function.Supplier<String> mapOverlay;
|
||||
private boolean registered;
|
||||
private JsonObject pending;
|
||||
private long nextSync;
|
||||
@@ -144,17 +145,39 @@ public final class Integration implements AutoCloseable {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
/** Start sending player positions for the in-app BlueMap territory view. */
|
||||
public void enableLiveMap() {
|
||||
if (closed || liveMap != null) return;
|
||||
liveMap = new LiveMapSync(client);
|
||||
liveMap.start();
|
||||
/**
|
||||
* Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims
|
||||
* overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles.
|
||||
*/
|
||||
public void enableMap(java.nio.file.Path worldRoot, java.nio.file.Path cacheDir) {
|
||||
if (closed || map != null) return;
|
||||
map = new net.scopenet.worldmap.WorldMapSync(client, worldRoot, cacheDir, log);
|
||||
if (mapOverlay != null) map.setOverlay(mapOverlay);
|
||||
map.start();
|
||||
}
|
||||
|
||||
/** Where the map gets its claims and pins (JSON text); may be called before or after {@link #enableMap}. */
|
||||
public void setMapOverlay(java.util.function.Supplier<String> overlay) {
|
||||
mapOverlay = overlay;
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.setOverlay(overlay);
|
||||
}
|
||||
|
||||
/** Hand over a snapshot of player positions; call about once a second on the server thread. */
|
||||
public void liveMapPlayers(java.util.List<LiveMapSync.Player> players) {
|
||||
LiveMapSync map = liveMap;
|
||||
if (map != null) map.offerPlayers(players);
|
||||
public void mapPlayers(java.util.List<net.scopenet.worldmap.WorldMapSync.Player> players) {
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.offerPlayers(players);
|
||||
}
|
||||
|
||||
/** The world was just saved: pick up its changed regions soon. */
|
||||
public void mapSaved() {
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
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) {
|
||||
@@ -173,8 +196,8 @@ public final class Integration implements AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
LiveMapSync map = liveMap;
|
||||
if (map != null) map.close();
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.close();
|
||||
client.close();
|
||||
loginWorker.shutdownNow();
|
||||
activity.leaveAll();
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
|
||||
public final class LiveMapSync implements AutoCloseable {
|
||||
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
|
||||
|
||||
private final PanelClient client;
|
||||
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(task, "scopenet-positions");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private volatile List<Player> players = List.of();
|
||||
private volatile boolean changed;
|
||||
private volatile boolean closed;
|
||||
private long lastSent;
|
||||
|
||||
public LiveMapSync(PanelClient client) { this.client = client; }
|
||||
|
||||
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
|
||||
|
||||
public void offerPlayers(List<Player> next) {
|
||||
if (!next.equals(players)) {
|
||||
players = List.copyOf(next);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSafely() {
|
||||
if (closed) return;
|
||||
long now = System.nanoTime();
|
||||
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
|
||||
JsonArray list = new JsonArray();
|
||||
for (Player p : players) {
|
||||
JsonObject entry = new JsonObject();
|
||||
entry.addProperty("uuid", p.uuid());
|
||||
entry.addProperty("name", p.name());
|
||||
entry.addProperty("dimension", p.dimension());
|
||||
entry.addProperty("x", p.x());
|
||||
entry.addProperty("y", p.y());
|
||||
entry.addProperty("z", p.z());
|
||||
entry.addProperty("yaw", p.yaw());
|
||||
entry.addProperty("pitch", p.pitch());
|
||||
list.add(entry);
|
||||
}
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("players", list);
|
||||
try {
|
||||
client.post("livemap/players", body);
|
||||
changed = false;
|
||||
lastSent = now;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception ignored) {
|
||||
// Keep the latest snapshot and retry on the next interval.
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -107,12 +107,12 @@ public record Settings(
|
||||
);
|
||||
}
|
||||
|
||||
/** Local opt-out for the central live map (the panel also has a per-server switch). */
|
||||
public static boolean liveMapEnabled(Path path) {
|
||||
/** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
|
||||
public static boolean mapEnabled(Path path) {
|
||||
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
Properties properties = new Properties();
|
||||
properties.load(reader);
|
||||
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
|
||||
return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true")));
|
||||
} catch (IOException e) {
|
||||
return true;
|
||||
}
|
||||
@@ -139,9 +139,33 @@ public record Settings(
|
||||
essentials.enabled=true
|
||||
economy.enabled=true
|
||||
|
||||
# Send world chunks and player positions to the panel's central Live Map.
|
||||
# It only runs when Live Map is also enabled for this server in the panel.
|
||||
livemap.enabled=true
|
||||
# Essentials limits. Players can exceed max_homes with the permission scopenet.homes.<number>
|
||||
# (or scopenet.homes.unlimited). Cooldowns are seconds, 0 = none; scopenet.cooldown.bypass skips them.
|
||||
essentials.max_homes=5
|
||||
essentials.home_name_max_length=32
|
||||
essentials.max_warps=0
|
||||
essentials.tpa_timeout_seconds=60
|
||||
essentials.rtp_radius=2500
|
||||
essentials.rtp_min_radius=0
|
||||
essentials.rtp_attempts=15
|
||||
essentials.cooldown.spawn=0
|
||||
essentials.cooldown.home=0
|
||||
essentials.cooldown.back=0
|
||||
essentials.cooldown.tpa=0
|
||||
essentials.cooldown.rtp=0
|
||||
essentials.cooldown.warp=0
|
||||
|
||||
# Rewards from quests, achievements and rank milestones (items, permissions, groups, messages).
|
||||
# allow_commands lets the panel run its own console commands as rewards: leave off unless you trust every panel admin.
|
||||
rewards.enabled=true
|
||||
rewards.allow_commands=false
|
||||
rewards.permission_command=lp user {player} permission set {node} true
|
||||
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
|
||||
rewards.group_command=lp user {player} parent add {group}
|
||||
|
||||
# Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
|
||||
# It only runs when the map is also enabled for this server in the panel.
|
||||
map.enabled=true
|
||||
""";
|
||||
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static net.scopenet.worldmap.MapColors.*;
|
||||
|
||||
/**
|
||||
* Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by
|
||||
* name keywords, then falls back to a stable neutral colour so the same block always looks the same.
|
||||
*/
|
||||
public final class BlockColors {
|
||||
private BlockColors() {}
|
||||
|
||||
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Set<String> CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane",
|
||||
"tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot",
|
||||
"moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air");
|
||||
private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"};
|
||||
private static final Set<String> WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass");
|
||||
|
||||
private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan",
|
||||
"purple", "blue", "brown", "green", "red", "black"};
|
||||
private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE,
|
||||
BROWN, GREEN, RED, BLACK};
|
||||
private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51};
|
||||
private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"};
|
||||
|
||||
private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_",
|
||||
"infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"};
|
||||
private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign",
|
||||
"_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"};
|
||||
|
||||
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
|
||||
|
||||
/** Water is special-cased by the renderer, which shades it by depth. */
|
||||
public static final int WATER_COLOR = 0xFF3F76E4;
|
||||
|
||||
private static final Map<String, Integer> TEXTURES = loadTextures();
|
||||
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
|
||||
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
|
||||
*/
|
||||
public static int of(String blockName) {
|
||||
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
|
||||
}
|
||||
|
||||
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
|
||||
public static int mapColor(String blockName) {
|
||||
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
|
||||
}
|
||||
|
||||
private static int resolve(String full) {
|
||||
int id = mapColor(full);
|
||||
if (id == 0) return 0;
|
||||
if (id == WATER) return WATER_COLOR;
|
||||
String n = plain(full);
|
||||
Integer exact = TEXTURES.get(n);
|
||||
if (exact != null) return exact;
|
||||
String s = n;
|
||||
for (boolean changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
|
||||
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
|
||||
if (!changed) break;
|
||||
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
|
||||
Integer hit = TEXTURES.get(candidate);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return MapColors.base(id);
|
||||
}
|
||||
|
||||
private static String plain(String full) {
|
||||
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static Map<String, Integer> loadTextures() {
|
||||
Map<String, Integer> out = new java.util.HashMap<>();
|
||||
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
|
||||
if (in == null) return out;
|
||||
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
|
||||
int comma = line.indexOf(',');
|
||||
if (comma <= 0 || comma + 7 > line.length()) continue;
|
||||
try {
|
||||
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// a malformed row just falls back to the map colour table
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
// no texture table: the map colour table covers everything
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int classify(String full) {
|
||||
String n = plain(full);
|
||||
int c = lookup(n);
|
||||
if (c >= 0) return c;
|
||||
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
|
||||
String s = n;
|
||||
for (boolean changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
|
||||
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
|
||||
if (changed && (c = lookup(s)) >= 0) return c;
|
||||
}
|
||||
return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)];
|
||||
}
|
||||
|
||||
/** The colour for a plain name, or -1 when nothing matches. */
|
||||
private static int lookup(String n) {
|
||||
if (CLEAR.contains(n)) return NONE;
|
||||
for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE;
|
||||
if (WATERY.contains(n)) return WATER;
|
||||
if (n.equals("lava")) return FIRE;
|
||||
for (int i = 0; i < DYES.length; i++) {
|
||||
String d = DYES[i] + "_";
|
||||
if (!n.startsWith(d)) continue;
|
||||
String rest = n.substring(d.length());
|
||||
if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i];
|
||||
for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i];
|
||||
}
|
||||
if (n.equals("terracotta")) return ORANGE;
|
||||
if (n.equals("grass_block") || n.equals("slime_block")) return GRASS;
|
||||
if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE
|
||||
: n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1;
|
||||
if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE
|
||||
: n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1;
|
||||
if (n.contains("deepslate")) return DEEPSLATE;
|
||||
if (n.contains("tuff")) return TERRACOTTA_GRAY;
|
||||
if (n.contains("calcite")) return TERRACOTTA_WHITE;
|
||||
if (n.contains("dripstone")) return TERRACOTTA_BROWN;
|
||||
if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK;
|
||||
if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER;
|
||||
if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED;
|
||||
if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN;
|
||||
if (n.equals("glowstone")) return SAND;
|
||||
if (n.equals("bedrock")) return STONE;
|
||||
if (n.contains("red_sand")) return ORANGE;
|
||||
if (n.contains("end_stone")) return SAND;
|
||||
if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND;
|
||||
if (n.contains("gravel")) return STONE;
|
||||
if (n.contains("podzol")) return PODZOL;
|
||||
if (n.contains("mycelium")) return PURPLE;
|
||||
if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN;
|
||||
if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT;
|
||||
if (n.equals("clay")) return CLAY;
|
||||
if (n.contains("snow")) return SNOW;
|
||||
if (n.equals("ice") || n.endsWith("_ice")) return ICE;
|
||||
if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM
|
||||
: n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE;
|
||||
if (n.equals("raw_iron_block")) return RAW_IRON;
|
||||
if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD;
|
||||
if (n.equals("raw_copper_block")) return ORANGE;
|
||||
if (n.equals("diamond_block")) return DIAMOND;
|
||||
if (n.equals("emerald_block")) return EMERALD;
|
||||
if (n.equals("lapis_block")) return LAPIS;
|
||||
if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE;
|
||||
if (n.contains("amethyst")) return PURPLE;
|
||||
if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ;
|
||||
if (n.contains("andesite")) return STONE;
|
||||
if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA;
|
||||
if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND;
|
||||
if (n.contains("prismarine")) return CYAN;
|
||||
if (n.contains("_ore")) return STONE;
|
||||
if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron")
|
||||
|| n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL;
|
||||
if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer")
|
||||
|| n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE;
|
||||
if (n.equals("hay_block")) return YELLOW;
|
||||
if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW;
|
||||
if (n.contains("melon")) return LIGHT_GREEN;
|
||||
if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE;
|
||||
if (n.equals("glow_lichen")) return GLOW_LICHEN;
|
||||
if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN;
|
||||
if (n.contains("red_mushroom")) return RED;
|
||||
if (n.contains("brown_mushroom")) return DIRT;
|
||||
if (n.equals("mushroom_stem")) return WOOL;
|
||||
if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK;
|
||||
if (n.equals("beacon")) return DIAMOND;
|
||||
if (n.contains("cherry_leaves")) return PINK;
|
||||
if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea")
|
||||
|| n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip")
|
||||
|| n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet")
|
||||
|| n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony")
|
||||
|| n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes")
|
||||
|| n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush")
|
||||
|| n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT;
|
||||
if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD;
|
||||
if (n.contains("mangrove")) return RED;
|
||||
if (n.contains("spruce")) return PODZOL;
|
||||
if (n.contains("birch")) return SAND;
|
||||
if (n.contains("jungle")) return DIRT;
|
||||
if (n.contains("acacia")) return ORANGE;
|
||||
if (n.contains("cherry")) return TERRACOTTA_WHITE;
|
||||
if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest")
|
||||
|| n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom")
|
||||
|| n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD;
|
||||
if (n.contains("coal")) return BLACK;
|
||||
if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL;
|
||||
if (n.contains("wool") || n.contains("carpet")) return WOOL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */
|
||||
public final class ChunkSampler {
|
||||
private ChunkSampler() {}
|
||||
|
||||
public enum Kind { OVERWORLD, NETHER, END }
|
||||
|
||||
/** Per-column results, indexed {@code z * 16 + x}. */
|
||||
public static final class Columns {
|
||||
public final int[] color = new int[256];
|
||||
public final int[] height = new int[256];
|
||||
/** For water: how many blocks of water sit above the floor. */
|
||||
public final int[] depth = new int[256];
|
||||
}
|
||||
|
||||
private record Section(int y, int[] colors, long[] data, int bits) {
|
||||
int colorAt(int x, int y, int z) {
|
||||
if (data == null) return colors[0];
|
||||
int index = y << 8 | z << 4 | x;
|
||||
int perLong = 64 / bits;
|
||||
int word = index / perLong;
|
||||
if (word >= data.length) return 0;
|
||||
int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1));
|
||||
return v < colors.length ? colors[v] : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the chunk was fully generated and could be read (the columns are then filled in). */
|
||||
public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) {
|
||||
String status = chunk.string("Status", "minecraft:full");
|
||||
if (!status.endsWith("full")) return false;
|
||||
List<Section> sections = new ArrayList<>();
|
||||
for (Object o : chunk.list("sections")) {
|
||||
if (!(o instanceof Nbt.Compound s)) continue;
|
||||
Nbt.Compound states = s.compound("block_states");
|
||||
if (states == null) continue;
|
||||
List<Object> palette = states.list("palette");
|
||||
if (palette.isEmpty()) continue;
|
||||
int[] colors = new int[palette.size()];
|
||||
for (int i = 0; i < colors.length; i++) {
|
||||
colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0;
|
||||
}
|
||||
long[] data = states.longs("data");
|
||||
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1));
|
||||
sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits));
|
||||
}
|
||||
if (sections.isEmpty()) return false;
|
||||
sections.sort((a, b) -> Integer.compare(b.y(), a.y()));
|
||||
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
int i = z * 16 + x;
|
||||
out.color[i] = 0;
|
||||
out.height[i] = 0;
|
||||
out.depth[i] = 0;
|
||||
if (kind == Kind.NETHER) column(sections, x, z, out, i, true);
|
||||
else column(sections, x, z, out, i, false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void column(List<Section> sections, int x, int z, Columns out, int i, boolean roofed) {
|
||||
boolean pastRoof = !roofed;
|
||||
int roofColor = 0, roofY = 0;
|
||||
for (int si = 0; si < sections.size(); si++) {
|
||||
Section sec = sections.get(si);
|
||||
for (int ly = 15; ly >= 0; ly--) {
|
||||
int worldY = sec.y() * 16 + ly;
|
||||
if (roofed && worldY > 126) continue;
|
||||
int c = sec.colorAt(x, ly, z);
|
||||
if (!pastRoof) {
|
||||
// Under the nether ceiling: skip solid rock until the first open air.
|
||||
if (c == 0) pastRoof = true;
|
||||
else if (roofColor == 0) { roofColor = c; roofY = worldY; }
|
||||
continue;
|
||||
}
|
||||
if (c == 0) continue;
|
||||
out.color[i] = c;
|
||||
out.height[i] = worldY;
|
||||
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (roofed && roofColor != 0) { // a solid column: show the ceiling instead
|
||||
out.color[i] = roofColor;
|
||||
out.height[i] = roofY;
|
||||
}
|
||||
}
|
||||
|
||||
private static int waterDepth(List<Section> sections, int si, int ly, int x, int z) {
|
||||
int depth = 1;
|
||||
for (int s = si; s < sections.size(); s++) {
|
||||
Section sec = sections.get(s);
|
||||
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
|
||||
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
|
||||
if (++depth >= 64) return depth;
|
||||
}
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */
|
||||
public final class MapColors {
|
||||
private MapColors() {}
|
||||
|
||||
public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10,
|
||||
STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19,
|
||||
PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29,
|
||||
GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37,
|
||||
TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52,
|
||||
CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58,
|
||||
DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61;
|
||||
|
||||
private static final int[] RGB = {
|
||||
0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070,
|
||||
0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99,
|
||||
0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200,
|
||||
0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C,
|
||||
0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464,
|
||||
0xD8AF93, 0x7FA796};
|
||||
|
||||
public static final int COUNT = RGB.length;
|
||||
|
||||
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
|
||||
private static final int[] SHADE = {180, 220, 255, 135};
|
||||
|
||||
/** The colour's base RGB (opaque ARGB), or 0 for none. */
|
||||
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
|
||||
|
||||
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
|
||||
public static int argb(int id, int shade) {
|
||||
if (id <= 0 || id >= COUNT) return 0;
|
||||
int base = RGB[id], m = SHADE[shade & 3];
|
||||
int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255;
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */
|
||||
public final class Nbt {
|
||||
private Nbt() {}
|
||||
|
||||
/** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */
|
||||
public static final class Compound extends HashMap<String, Object> {
|
||||
public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; }
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Object> list(String key) { return get(key) instanceof List<?> l ? (List<Object>) l : List.of(); }
|
||||
|
||||
public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; }
|
||||
|
||||
public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; }
|
||||
|
||||
public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; }
|
||||
}
|
||||
|
||||
private static final int MAX_DEPTH = 64;
|
||||
private static final int MAX_ARRAY = 8 * 1024 * 1024;
|
||||
|
||||
/** Reads one named root compound (the form chunks are stored in). */
|
||||
public static Compound readRoot(DataInputStream in) throws IOException {
|
||||
byte type = in.readByte();
|
||||
if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type);
|
||||
in.readUTF(); // root name, usually empty
|
||||
return readCompound(in, 0);
|
||||
}
|
||||
|
||||
private static Compound readCompound(DataInputStream in, int depth) throws IOException {
|
||||
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
|
||||
Compound out = new Compound();
|
||||
while (true) {
|
||||
byte type = in.readByte();
|
||||
if (type == 0) return out;
|
||||
String name = in.readUTF();
|
||||
out.put(name, readValue(in, type, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static Object readValue(DataInputStream in, byte type, int depth) throws IOException {
|
||||
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
|
||||
switch (type) {
|
||||
case 1: return in.readByte();
|
||||
case 2: return in.readShort();
|
||||
case 3: return in.readInt();
|
||||
case 4: return in.readLong();
|
||||
case 5: return in.readFloat();
|
||||
case 6: return in.readDouble();
|
||||
case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; }
|
||||
case 8: return in.readUTF();
|
||||
case 9: {
|
||||
byte elementType = in.readByte();
|
||||
int n = length(in);
|
||||
List<Object> list = new ArrayList<>(Math.min(n, 4096));
|
||||
for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1));
|
||||
return list;
|
||||
}
|
||||
case 10: return readCompound(in, depth);
|
||||
case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; }
|
||||
case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; }
|
||||
default: throw new IOException("unknown NBT tag " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private static int length(DataInputStream in) throws IOException {
|
||||
int n = in.readInt();
|
||||
if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.Deflater;
|
||||
|
||||
/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */
|
||||
public final class Png {
|
||||
private Png() {}
|
||||
|
||||
/**
|
||||
* Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth
|
||||
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
|
||||
*/
|
||||
public static byte[] encode(int[] argb, int width, int height) {
|
||||
argb = limitColors(argb);
|
||||
Map<Integer, Integer> palette = new LinkedHashMap<>();
|
||||
for (int c : argb) {
|
||||
int key = (c >>> 24) == 0 ? 0 : c;
|
||||
if (!palette.containsKey(key)) {
|
||||
palette.put(key, palette.size());
|
||||
if (palette.size() > 256) return rgba(argb, width, height);
|
||||
}
|
||||
}
|
||||
int n = palette.size();
|
||||
int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8;
|
||||
int rowBytes = (width * depth + 7) / 8;
|
||||
byte[] raw = new byte[height * (1 + rowBytes)];
|
||||
int p = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
raw[p++] = 0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int c = argb[y * width + x];
|
||||
int idx = palette.get((c >>> 24) == 0 ? 0 : c);
|
||||
if (depth == 8) raw[p + x] = (byte) idx;
|
||||
else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8));
|
||||
}
|
||||
p += rowBytes;
|
||||
}
|
||||
byte[] plte = new byte[n * 3];
|
||||
List<Integer> alpha = new ArrayList<>();
|
||||
for (var e : palette.entrySet()) {
|
||||
int c = e.getKey(), i = e.getValue();
|
||||
plte[i * 3] = (byte) (c >> 16);
|
||||
plte[i * 3 + 1] = (byte) (c >> 8);
|
||||
plte[i * 3 + 2] = (byte) c;
|
||||
alpha.add(c == 0 ? 0 : c >>> 24);
|
||||
}
|
||||
int last = -1;
|
||||
for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i;
|
||||
byte[] trns = new byte[last + 1];
|
||||
for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
|
||||
out.writeBytes(SIGNATURE);
|
||||
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array());
|
||||
chunk(out, "PLTE", plte);
|
||||
if (trns.length > 0) chunk(out, "tRNS", trns);
|
||||
chunk(out, "IDAT", deflate(raw));
|
||||
chunk(out, "IEND", new byte[0]);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
|
||||
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
|
||||
*/
|
||||
static int[] limitColors(int[] argb) {
|
||||
Map<Integer, Integer> counts = new HashMap<>();
|
||||
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
|
||||
if (counts.size() <= 255) return argb;
|
||||
List<Integer> keep = new ArrayList<>(counts.keySet());
|
||||
keep.sort((a, b) -> counts.get(b) - counts.get(a));
|
||||
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
|
||||
Set<Integer> kept = new HashSet<>(top);
|
||||
Map<Integer, Integer> remap = new HashMap<>();
|
||||
int[] out = argb.clone();
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int c = out[i];
|
||||
if ((c >>> 24) == 0 || kept.contains(c)) continue;
|
||||
out[i] = remap.computeIfAbsent(c, k -> {
|
||||
int best = top.get(0), bestD = Integer.MAX_VALUE;
|
||||
for (int q : top) {
|
||||
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
|
||||
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
|
||||
private static byte[] deflate(byte[] raw) {
|
||||
Deflater deflater = new Deflater(9);
|
||||
deflater.setInput(raw);
|
||||
deflater.finish();
|
||||
ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64);
|
||||
byte[] buf = new byte[16384];
|
||||
while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf));
|
||||
deflater.end();
|
||||
return z.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] rgba(int[] argb, int width, int height) {
|
||||
byte[] raw = new byte[height * (1 + width * 4)];
|
||||
int p = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
raw[p++] = 0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int c = argb[y * width + x];
|
||||
raw[p++] = (byte) (c >> 16);
|
||||
raw[p++] = (byte) (c >> 8);
|
||||
raw[p++] = (byte) c;
|
||||
raw[p++] = (byte) (c >>> 24);
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
|
||||
out.writeBytes(SIGNATURE);
|
||||
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array());
|
||||
chunk(out, "IDAT", deflate(raw));
|
||||
chunk(out, "IEND", new byte[0]);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void chunk(ByteArrayOutputStream out, String type, byte[] data) {
|
||||
out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array());
|
||||
byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
out.writeBytes(t);
|
||||
out.writeBytes(data);
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(t);
|
||||
crc.update(data);
|
||||
out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
/**
|
||||
* Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running:
|
||||
* a chunk caught mid-write simply fails to read and is retried on the next pass.
|
||||
*/
|
||||
public final class RegionFile implements AutoCloseable {
|
||||
private static final int SECTOR = 4096;
|
||||
|
||||
private final FileChannel channel;
|
||||
private final int[] locations = new int[1024];
|
||||
private final int[] timestamps = new int[1024];
|
||||
|
||||
public RegionFile(Path file) throws IOException {
|
||||
channel = FileChannel.open(file, StandardOpenOption.READ);
|
||||
try {
|
||||
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
|
||||
while (header.hasRemaining()) if (channel.read(header) < 0) break;
|
||||
header.flip();
|
||||
if (header.remaining() == 2 * SECTOR) {
|
||||
header.asIntBuffer().get(locations, 0, 1024);
|
||||
header.position(SECTOR);
|
||||
header.asIntBuffer().get(timestamps, 0, 1024);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
channel.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; }
|
||||
|
||||
/** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */
|
||||
public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; }
|
||||
|
||||
/**
|
||||
* The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read.
|
||||
* Region files written with LZ4 (a server option since 1.20.5) are skipped.
|
||||
*/
|
||||
public Nbt.Compound read(int index) {
|
||||
int loc = locations[index];
|
||||
if (loc == 0) return null;
|
||||
long offset = (long) (loc >>> 8) * SECTOR;
|
||||
int sectors = loc & 0xFF;
|
||||
if (offset < 2 * SECTOR || sectors == 0) return null;
|
||||
try {
|
||||
ByteBuffer head = ByteBuffer.allocate(5);
|
||||
if (read(head, offset) < 5) return null;
|
||||
head.flip();
|
||||
int length = head.getInt();
|
||||
int type = head.get() & 0xFF;
|
||||
if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file
|
||||
ByteBuffer body = ByteBuffer.allocate(length - 1);
|
||||
if (read(body, offset + 5) < length - 1) return null;
|
||||
InputStream raw = new ByteArrayInputStream(body.array());
|
||||
InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw;
|
||||
try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) {
|
||||
return Nbt.readRoot(in);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int read(ByteBuffer buffer, long position) throws IOException {
|
||||
int total = 0;
|
||||
while (buffer.hasRemaining()) {
|
||||
int n = channel.read(buffer, position + total);
|
||||
if (n < 0) break;
|
||||
total += n;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@Override public void close() throws IOException { channel.close(); }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded
|
||||
* by the slope towards the north, and water shaded by depth.
|
||||
*/
|
||||
public final class RegionRenderer {
|
||||
private RegionRenderer() {}
|
||||
|
||||
public static final int TILE = 256;
|
||||
private static final int SIDE = 512;
|
||||
|
||||
/** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */
|
||||
public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException {
|
||||
int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE];
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
boolean any = false;
|
||||
try (RegionFile region = new RegionFile(file)) {
|
||||
for (int i = 0; i < 1024; i++) {
|
||||
if (region.timestamp(i) == 0) continue;
|
||||
Nbt.Compound chunk = region.read(i);
|
||||
if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue;
|
||||
int cx = (i & 31) * 16, cz = (i >> 5) * 16;
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int dst = (cz + z) * SIDE + cx;
|
||||
System.arraycopy(cols.color, z * 16, colors, dst, 16);
|
||||
System.arraycopy(cols.height, z * 16, heights, dst, 16);
|
||||
System.arraycopy(cols.depth, z * 16, depths, dst, 16);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) return null;
|
||||
int[] pixels = shade(colors, heights, depths);
|
||||
int[][] tiles = new int[4][TILE * TILE];
|
||||
for (int ty = 0; ty < 2; ty++) {
|
||||
for (int tx = 0; tx < 2; tx++) {
|
||||
int[] tile = tiles[tx + ty * 2];
|
||||
for (int row = 0; row < TILE; row++) {
|
||||
System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
|
||||
* Colours are the blocks' own, so they stay in few steps and compress well.
|
||||
*/
|
||||
static int[] shade(int[] colors, int[] heights, int[] depths) {
|
||||
int[] out = new int[SIDE * SIDE];
|
||||
for (int z = 0; z < SIDE; z++) {
|
||||
for (int x = 0; x < SIDE; x++) {
|
||||
int i = z * SIDE + x;
|
||||
int c = colors[i];
|
||||
if (c == 0) continue;
|
||||
int percent;
|
||||
if (c == BlockColors.WATER_COLOR) {
|
||||
percent = 108 - Math.min(depths[i], 14) * 4;
|
||||
} else {
|
||||
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
|
||||
int diff = heights[i] - north;
|
||||
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
|
||||
}
|
||||
out[i] = scale(c, percent);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int scale(int argb, int percent) {
|
||||
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
|
||||
b = Math.min(255, (argb & 0xFF) * percent / 100);
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.*;
|
||||
|
||||
/**
|
||||
* The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per
|
||||
* block; every level up halves the resolution, down to {@code maxZoom}.
|
||||
*/
|
||||
public final class TileStore {
|
||||
public record Key(String dim, int zoom, int x, int y) {
|
||||
@Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; }
|
||||
|
||||
public static Key parse(String s) {
|
||||
String[] p = s.split("/");
|
||||
return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]));
|
||||
}
|
||||
}
|
||||
|
||||
private static final int SIZE = RegionRenderer.TILE;
|
||||
private static final int PIXELS = SIZE * SIZE;
|
||||
|
||||
private final Path root;
|
||||
private final Map<Key, int[]> memory = new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override protected boolean removeEldestEntry(Map.Entry<Key, int[]> e) { return size() > 96; }
|
||||
};
|
||||
|
||||
public TileStore(Path root) { this.root = root; }
|
||||
|
||||
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);
|
||||
if (cached != null) return cached;
|
||||
Path f = file(k);
|
||||
if (!Files.isRegularFile(f)) return null;
|
||||
try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
|
||||
byte[] raw = in.readAllBytes();
|
||||
if (raw.length != PIXELS * 4) return null;
|
||||
int[] pixels = new int[PIXELS];
|
||||
ByteBuffer.wrap(raw).asIntBuffer().get(pixels);
|
||||
memory.put(k, pixels);
|
||||
return pixels;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void save(Key k, int[] pixels) throws IOException {
|
||||
ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4);
|
||||
raw.asIntBuffer().put(pixels);
|
||||
Path f = file(k);
|
||||
Files.createDirectories(f.getParent());
|
||||
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
|
||||
try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) {
|
||||
out.write(raw.array());
|
||||
}
|
||||
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
|
||||
memory.put(k, pixels);
|
||||
}
|
||||
|
||||
public synchronized void clear() throws IOException {
|
||||
memory.clear();
|
||||
if (!Files.isDirectory(root)) return;
|
||||
try (var walk = Files.walk(root)) {
|
||||
for (Path p : (Iterable<Path>) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean blank(int[] pixels) {
|
||||
for (int p : pixels) if ((p >>> 24) != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stores a freshly rendered tile and returns true if it differs from what was there. */
|
||||
public boolean update(Key k, int[] pixels) throws IOException {
|
||||
int[] old = load(k);
|
||||
if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false;
|
||||
save(k, pixels);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null.
|
||||
*/
|
||||
public Key rebuildParent(Key child) throws IOException {
|
||||
int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1;
|
||||
int[] out = new int[PIXELS];
|
||||
for (int qy = 0; qy < 2; qy++) {
|
||||
for (int qx = 0; qx < 2; qx++) {
|
||||
int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy));
|
||||
if (src == null) continue;
|
||||
int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2);
|
||||
for (int y = 0; y < SIZE / 2; y++) {
|
||||
for (int x = 0; x < SIZE / 2; x++) {
|
||||
out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1],
|
||||
src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Key parent = new Key(child.dim(), pz, px, py);
|
||||
return update(parent, out) ? parent : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps
|
||||
* zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well.
|
||||
*/
|
||||
static int average(int a, int b, int c, int d) {
|
||||
int[] px = {a, b, c, d};
|
||||
int best = 0, bestCount = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if ((px[i] >>> 24) == 0) continue;
|
||||
int count = 0;
|
||||
for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++;
|
||||
if (count > bestCount) { best = px[i]; bestCount = count; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import com.google.gson.*;
|
||||
import net.scopenet.integration.PanelClient;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one
|
||||
* region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along.
|
||||
* Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}.
|
||||
*/
|
||||
public final class WorldMapSync implements AutoCloseable {
|
||||
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
|
||||
|
||||
private record Dim(String id, String dir, ChunkSampler.Kind kind) {}
|
||||
|
||||
private static final Dim[] DIMS = {
|
||||
new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD),
|
||||
new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER),
|
||||
new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)};
|
||||
private static final int BATCH_BYTES = 3 * 1024 * 1024;
|
||||
private static final long RERENDER_GAP_MS = 90_000;
|
||||
private static final long COARSE_UPLOAD_GAP_MS = 60_000;
|
||||
|
||||
private static final class State {
|
||||
long epoch = -1;
|
||||
/** "dim/x.z" -> file mtime (ms) at the time it was rendered. */
|
||||
Map<String, Long> regions = new HashMap<>();
|
||||
/** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */
|
||||
Set<String> dirty = new HashSet<>();
|
||||
}
|
||||
|
||||
private final PanelClient client;
|
||||
private final Path worldRoot;
|
||||
private final Path cacheDir;
|
||||
private final Consumer<String> log;
|
||||
private final Gson gson = new Gson();
|
||||
private final TileStore store;
|
||||
private final Object wake = new Object();
|
||||
private final Thread thread;
|
||||
private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "scopenet-map-live");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private State state = new State();
|
||||
private final Map<String, Long> renderedAt = new HashMap<>();
|
||||
private volatile boolean closed;
|
||||
private volatile boolean enabled;
|
||||
private volatile boolean wakeRequested;
|
||||
private volatile int maxZoom = 6;
|
||||
private volatile long playerIntervalMs = 2000;
|
||||
private volatile List<Player> players = List.of();
|
||||
private volatile java.util.function.Supplier<String> overlay;
|
||||
private long configAt;
|
||||
private long coarseUploadAt;
|
||||
private boolean stateDirty;
|
||||
private boolean warnedMissing;
|
||||
private String lastOverlaySent;
|
||||
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;
|
||||
this.worldRoot = worldRoot;
|
||||
this.cacheDir = cacheDir;
|
||||
this.log = log;
|
||||
this.store = new TileStore(cacheDir.resolve("tiles"));
|
||||
thread = new Thread(this::run, "scopenet-map");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
loadState();
|
||||
thread.start();
|
||||
live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void offerPlayers(List<Player> 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. */
|
||||
public void setOverlay(java.util.function.Supplier<String> source) {
|
||||
overlay = source;
|
||||
}
|
||||
|
||||
/** The world was saved: look for changed regions soon. */
|
||||
public void requestScan() {
|
||||
synchronized (wake) {
|
||||
wakeRequested = true;
|
||||
wake.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
live.shutdownNow();
|
||||
thread.interrupt();
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ---- main loop ----
|
||||
|
||||
private void run() {
|
||||
while (!closed) {
|
||||
long pause = 10_000;
|
||||
try {
|
||||
if (!checkConfig()) pause = 60_000;
|
||||
else {
|
||||
boolean worked = cycle();
|
||||
pause = worked ? 150 : 10_000;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
} 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);
|
||||
wakeRequested = false;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkConfig() throws IOException, InterruptedException {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now < configAt) return enabled;
|
||||
configAt = now + 60_000;
|
||||
try {
|
||||
JsonObject config = client.get("map/config");
|
||||
boolean on = config.has("enabled") && config.get("enabled").getAsBoolean();
|
||||
if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt()));
|
||||
if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong());
|
||||
long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0;
|
||||
if (on && epoch != state.epoch) {
|
||||
if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again.");
|
||||
store.clear();
|
||||
state = new State();
|
||||
state.epoch = epoch;
|
||||
renderedAt.clear();
|
||||
stateDirty = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
rx = Integer.parseInt(parts[1]);
|
||||
rz = Integer.parseInt(parts[2]);
|
||||
} 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 (last != null && now - last < RERENDER_GAP_MS) continue;
|
||||
double distance = Double.MAX_VALUE;
|
||||
for (Player p : online) {
|
||||
if (!dim.id().equals(p.dimension())) continue;
|
||||
double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256);
|
||||
distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
|
||||
}
|
||||
stale.add(new Stale(dim, file, key, mtime, rx, rz, distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
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 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());
|
||||
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++) {
|
||||
TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1));
|
||||
if (store.update(k, tiles[i])) {
|
||||
changed.add(k);
|
||||
state.dirty.add(k.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rebuild the zoom pyramid above whatever changed.
|
||||
List<TileStore.Key> level = changed;
|
||||
for (int z = 0; z < maxZoom && !level.isEmpty(); z++) {
|
||||
Set<Long> seen = new HashSet<>();
|
||||
List<TileStore.Key> next = new ArrayList<>();
|
||||
for (TileStore.Key child : level) {
|
||||
long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL);
|
||||
if (!seen.add(id)) continue;
|
||||
TileStore.Key parent = store.rebuildParent(child);
|
||||
if (parent != null) {
|
||||
next.add(parent);
|
||||
state.dirty.add(parent.toString());
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
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. */
|
||||
private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException {
|
||||
if (state.dirty.isEmpty()) return false;
|
||||
long now = System.currentTimeMillis();
|
||||
boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS;
|
||||
List<TileStore.Key> ready = new ArrayList<>();
|
||||
for (String s : state.dirty) {
|
||||
TileStore.Key k = TileStore.Key.parse(s);
|
||||
if (k.zoom() == 0 || coarseNow) ready.add(k);
|
||||
}
|
||||
if (ready.isEmpty()) return false;
|
||||
ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed()));
|
||||
if (coarseNow) coarseUploadAt = now;
|
||||
boolean sentAny = false;
|
||||
int batches = 0;
|
||||
int i = 0;
|
||||
while (i < ready.size() && batches < 8 && !closed) {
|
||||
String dimension = ready.get(i).dim();
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
List<TileStore.Key> inBatch = new ArrayList<>();
|
||||
while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) {
|
||||
TileStore.Key k = ready.get(i++);
|
||||
int[] pixels = store.load(k);
|
||||
if (pixels == null) {
|
||||
state.dirty.remove(k.toString());
|
||||
continue;
|
||||
}
|
||||
byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE);
|
||||
body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array());
|
||||
body.writeBytes(png);
|
||||
inBatch.add(k);
|
||||
}
|
||||
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;
|
||||
batches++;
|
||||
}
|
||||
return sentAny;
|
||||
}
|
||||
|
||||
private static String idOf(String dir) {
|
||||
for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id();
|
||||
return "minecraft:" + dir;
|
||||
}
|
||||
|
||||
// ---- live data ----
|
||||
|
||||
private void sendLive() {
|
||||
if (closed || !enabled) return;
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
List<Player> snapshot = players;
|
||||
JsonArray list = new JsonArray();
|
||||
for (Player p : snapshot) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("uuid", p.uuid());
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("dimension", p.dimension());
|
||||
o.addProperty("x", Math.round(p.x() * 10) / 10.0);
|
||||
o.addProperty("y", Math.round(p.y() * 10) / 10.0);
|
||||
o.addProperty("z", Math.round(p.z() * 10) / 10.0);
|
||||
o.addProperty("yaw", Math.round(p.yaw()));
|
||||
list.add(o);
|
||||
}
|
||||
String text = list.toString();
|
||||
if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("players", list);
|
||||
client.post("map/players", body);
|
||||
lastPlayersSent = text;
|
||||
playersSentAt = now;
|
||||
}
|
||||
java.util.function.Supplier<String> source = overlay;
|
||||
String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get();
|
||||
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
|
||||
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
|
||||
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;
|
||||
lastError = "live upload HTTP " + e.status + ": " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
lastError = "live upload: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
private long diagnosticsSentAt;
|
||||
|
||||
// ---- world layout ----
|
||||
|
||||
private Path regionDir(Dim dim) {
|
||||
String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString();
|
||||
Path parent = worldRoot.toAbsolutePath().getParent();
|
||||
List<Path> candidates = switch (dim.dir()) {
|
||||
case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
|
||||
worldRoot.resolve("dimensions/minecraft/the_nether/region"));
|
||||
case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
|
||||
worldRoot.resolve("dimensions/minecraft/the_end/region"));
|
||||
default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region"));
|
||||
};
|
||||
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Path sibling(Path parent, String name, String rel) {
|
||||
return parent == null ? null : parent.resolve(name).resolve(rel);
|
||||
}
|
||||
|
||||
// ---- state ----
|
||||
|
||||
private void loadState() {
|
||||
try {
|
||||
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;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveState() {
|
||||
try {
|
||||
Files.createDirectories(cacheDir);
|
||||
Path f = cacheDir.resolve("state.json");
|
||||
Path tmp = f.resolveSibling("state.json.tmp");
|
||||
Files.writeString(tmp, gson.toJson(state));
|
||||
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
|
||||
stateDirty = false;
|
||||
} catch (IOException e) {
|
||||
// the cache is only an optimisation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
stone,989898
|
||||
granite,B6775E
|
||||
polished_granite,BC7A63
|
||||
diorite,E5E5E5
|
||||
polished_diorite,EAEBED
|
||||
andesite,A6A6A6
|
||||
polished_andesite,A1A3A2
|
||||
grass_block,618149
|
||||
dirt,A37048
|
||||
coarse_dirt,91633F
|
||||
podzol,6F4913
|
||||
cobblestone,9B9B9B
|
||||
oak_planks,C69A53
|
||||
spruce_planks,8B6231
|
||||
birch_planks,EAD389
|
||||
jungle_planks,C38656
|
||||
acacia_planks,CD622C
|
||||
cherry_planks,FFC2BB
|
||||
dark_oak_planks,513112
|
||||
mangrove_planks,8F3930
|
||||
bamboo_planks,EBD051
|
||||
bamboo_mosaic,E8CC4F
|
||||
bedrock,686868
|
||||
sand,FFEFB6
|
||||
red_sand,E87011
|
||||
gravel,A09A99
|
||||
gold_ore,B1A17C
|
||||
deepslate_gold_ore,8C7B5A
|
||||
iron_ore,A69C93
|
||||
deepslate_iron_ore,817871
|
||||
coal_ore,808080
|
||||
deepslate_coal_ore,5A5A5D
|
||||
nether_gold_ore,8C3929
|
||||
oak_log,B88F4E
|
||||
spruce_log,845E2F
|
||||
birch_log,EBD89C
|
||||
jungle_log,B67F4A
|
||||
acacia_log,B76235
|
||||
cherry_log,E2A6A0
|
||||
dark_oak_log,523414
|
||||
mangrove_log,7C332A
|
||||
mangrove_roots,5A4629
|
||||
muddy_mangrove_roots,554533
|
||||
bamboo_block,A9AC40
|
||||
stripped_spruce_log,805E2F
|
||||
stripped_birch_log,E9CE83
|
||||
stripped_jungle_log,C98F57
|
||||
stripped_acacia_log,CB642D
|
||||
stripped_cherry_log,FFB5AC
|
||||
stripped_dark_oak_log,4F3315
|
||||
stripped_oak_log,C39952
|
||||
stripped_mangrove_log,852B2B
|
||||
stripped_bamboo_block,D9BE48
|
||||
oak_leaves,4D7515
|
||||
spruce_leaves,355C35
|
||||
jungle_leaves,557E14
|
||||
cherry_leaves,FFB8D3
|
||||
dark_oak_leaves,517B16
|
||||
mangrove_leaves,456913
|
||||
azalea_leaves,6A8B2B
|
||||
flowering_azalea_leaves,778742
|
||||
sponge,EEEA49
|
||||
wet_sponge,CFDD45
|
||||
lapis_ore,7E8BAC
|
||||
deepslate_lapis_ore,5B6A8C
|
||||
lapis_block,1447AB
|
||||
dispenser,959393
|
||||
sandstone,FFF3BB
|
||||
chiseled_sandstone,FFECAE
|
||||
cut_sandstone,FFF1B3
|
||||
note_block,6B422A
|
||||
powered_rail,A78151
|
||||
detector_rail,967C69
|
||||
piston,BB975E
|
||||
white_wool,FBFFFF
|
||||
orange_wool,FF7200
|
||||
magenta_wool,E741D9
|
||||
light_blue_wool,2EC8FF
|
||||
yellow_wool,FFC40E
|
||||
lime_wool,7EE207
|
||||
pink_wool,FF8BB1
|
||||
gray_wool,4A5357
|
||||
light_gray_wool,ADADA2
|
||||
cyan_wool,07A6B1
|
||||
purple_wool,8C20D2
|
||||
blue_wool,3137C0
|
||||
brown_wool,8B5026
|
||||
green_wool,638515
|
||||
red_wool,C31E17
|
||||
black_wool,18191E
|
||||
gold_block,FFD328
|
||||
iron_block,FFFFFF
|
||||
bricks,B76F5B
|
||||
tnt,AD4034
|
||||
bookshelf,8F6F3F
|
||||
chiseled_bookshelf,D9AB5E
|
||||
mossy_cobblestone,85906F
|
||||
obsidian,110A1D
|
||||
spawner,28364C
|
||||
diamond_ore,91ACAB
|
||||
deepslate_diamond_ore,628181
|
||||
diamond_block,57FFF4
|
||||
crafting_table,915228
|
||||
farmland,AE764B
|
||||
furnace,868585
|
||||
oak_door,AB8246
|
||||
iron_door,EDEBEB
|
||||
redstone_ore,AB8080
|
||||
deepslate_redstone_ore,7F5556
|
||||
snow,F9FFFF
|
||||
ice,85B0FF
|
||||
cactus,629B28
|
||||
clay,C0C9DA
|
||||
jukebox,714A33
|
||||
netherrack,762626
|
||||
soul_sand,634938
|
||||
soul_soil,5C4334
|
||||
basalt,616269
|
||||
polished_basalt,79777A
|
||||
glowstone,D19A5A
|
||||
nether_portal,6400EA
|
||||
carved_pumpkin,B75D01
|
||||
jack_o_lantern,FFAC27
|
||||
cake,FFE1D8
|
||||
repeater,C3BFBE
|
||||
oak_trapdoor,97753A
|
||||
spruce_trapdoor,7E5D31
|
||||
birch_trapdoor,FDEBB8
|
||||
jungle_trapdoor,B98053
|
||||
acacia_trapdoor,BE602F
|
||||
cherry_trapdoor,FFC2BB
|
||||
dark_oak_trapdoor,5C3814
|
||||
mangrove_trapdoor,862F29
|
||||
bamboo_trapdoor,F2D857
|
||||
stone_bricks,959395
|
||||
mossy_stone_bricks,8B947E
|
||||
cracked_stone_bricks,908F90
|
||||
chiseled_stone_bricks,919091
|
||||
packed_mud,AD7C57
|
||||
mud_bricks,A77958
|
||||
brown_mushroom_block,B68259
|
||||
red_mushroom_block,F42220
|
||||
mushroom_stem,F8EEDF
|
||||
pumpkin,F28404
|
||||
melon,83B014
|
||||
mycelium,87767A
|
||||
nether_bricks,36161D
|
||||
enchanting_table,9C5461
|
||||
cauldron,59585A
|
||||
end_portal_frame,6B9273
|
||||
end_stone,FBFFAD
|
||||
dragon_egg,0E0A12
|
||||
redstone_lamp,743C1B
|
||||
emerald_ore,80A689
|
||||
deepslate_emerald_ore,5B7F68
|
||||
emerald_block,1CF859
|
||||
command_block,D89C78
|
||||
beacon,79FFF9
|
||||
anvil,585858
|
||||
chipped_anvil,585858
|
||||
damaged_anvil,585858
|
||||
comparator,CBC4C1
|
||||
daylight_detector,9F8B6D
|
||||
redstone_block,D61800
|
||||
nether_quartz_ore,8F4844
|
||||
hopper,5C5A5C
|
||||
quartz_block,FFF8EF
|
||||
chiseled_quartz_block,FFF9EE
|
||||
quartz_pillar,FFF8EF
|
||||
activator_rail,8C6654
|
||||
dropper,959393
|
||||
white_terracotta,FFD5BD
|
||||
orange_terracotta,C45A1B
|
||||
magenta_terracotta,B6627E
|
||||
light_blue_terracotta,8680A7
|
||||
yellow_terracotta,E39B15
|
||||
lime_terracotta,7C8F36
|
||||
pink_terracotta,C45353
|
||||
gray_terracotta,463127
|
||||
light_gray_terracotta,A57D71
|
||||
cyan_terracotta,686F6F
|
||||
purple_terracotta,904E64
|
||||
blue_terracotta,58436F
|
||||
brown_terracotta,5E3A25
|
||||
green_terracotta,5C652D
|
||||
red_terracotta,AE3E2A
|
||||
black_terracotta,2D1910
|
||||
slime_block,7CEA60
|
||||
iron_trapdoor,F6F6F6
|
||||
prismarine,6EC6AE
|
||||
prismarine_bricks,6ED1BF
|
||||
dark_prismarine,386F59
|
||||
sea_lantern,CEF4E6
|
||||
hay_block,C9A700
|
||||
terracotta,B96A45
|
||||
coal_block,141212
|
||||
packed_ice,82AFFF
|
||||
red_sandstone,DD6A10
|
||||
chiseled_red_sandstone,DF680A
|
||||
cut_red_sandstone,E76E0F
|
||||
smooth_stone_slab,CCCCCC
|
||||
smooth_stone,C1C1C1
|
||||
bamboo_fence_gate,FBDF57
|
||||
bamboo_fence,FBE05B
|
||||
spruce_door,815E32
|
||||
birch_door,FFF1C6
|
||||
jungle_door,C78B5B
|
||||
acacia_door,CC693A
|
||||
cherry_door,FFBBB3
|
||||
dark_oak_door,5D3B17
|
||||
mangrove_door,89312E
|
||||
bamboo_door,E9CE53
|
||||
chorus_plant,714071
|
||||
chorus_flower,B88EB8
|
||||
purpur_block,CE92CE
|
||||
purpur_pillar,D196D1
|
||||
end_stone_bricks,F7FFB0
|
||||
dirt_path,B59043
|
||||
repeating_command_block,967DCF
|
||||
chain_command_block,9BC4B1
|
||||
nether_wart_block,8B0000
|
||||
red_nether_bricks,540003
|
||||
bone_block,FFFBD6
|
||||
observer,787878
|
||||
shulker_box,AA6FAA
|
||||
white_shulker_box,F7FEFF
|
||||
orange_shulker_box,FF6F00
|
||||
magenta_shulker_box,D330C5
|
||||
light_blue_shulker_box,23BDFF
|
||||
yellow_shulker_box,FFBA03
|
||||
lime_shulker_box,6ED206
|
||||
pink_shulker_box,FF78A4
|
||||
gray_shulker_box,42464C
|
||||
light_gray_shulker_box,97978B
|
||||
cyan_shulker_box,0892A5
|
||||
purple_shulker_box,7615BE
|
||||
blue_shulker_box,2629AB
|
||||
brown_shulker_box,814B20
|
||||
green_shulker_box,5D7A1C
|
||||
red_shulker_box,AB1614
|
||||
black_shulker_box,1E1E23
|
||||
white_glazed_terracotta,DFFFF2
|
||||
orange_glazed_terracotta,BCB266
|
||||
magenta_glazed_terracotta,FE6AE7
|
||||
light_blue_glazed_terracotta,62C2FE
|
||||
yellow_glazed_terracotta,FFCC4D
|
||||
lime_glazed_terracotta,C1F02E
|
||||
pink_glazed_terracotta,FF9DBD
|
||||
gray_glazed_terracotta,646D71
|
||||
light_gray_glazed_terracotta,ACCACC
|
||||
cyan_glazed_terracotta,358F98
|
||||
purple_glazed_terracotta,7F2AB9
|
||||
blue_glazed_terracotta,2C43AA
|
||||
brown_glazed_terracotta,917F63
|
||||
green_glazed_terracotta,8BAD47
|
||||
red_glazed_terracotta,DD362E
|
||||
black_glazed_terracotta,521F22
|
||||
white_concrete,F6FEFF
|
||||
orange_concrete,FF6E00
|
||||
magenta_concrete,CE29C1
|
||||
light_blue_concrete,139EF2
|
||||
yellow_concrete,FFB300
|
||||
lime_concrete,68CD08
|
||||
pink_concrete,FF69A0
|
||||
gray_concrete,41454A
|
||||
light_gray_concrete,98988B
|
||||
cyan_concrete,098FA6
|
||||
purple_concrete,7214BE
|
||||
blue_concrete,272AAE
|
||||
brown_concrete,75431C
|
||||
green_concrete,566F24
|
||||
red_concrete,AD1717
|
||||
black_concrete,090B12
|
||||
white_concrete_powder,FCFFFF
|
||||
orange_concrete_powder,FF8608
|
||||
magenta_concrete_powder,EA55DF
|
||||
light_blue_concrete_powder,45D3FF
|
||||
yellow_concrete_powder,FFD624
|
||||
lime_concrete_powder,8FE71C
|
||||
pink_concrete_powder,FFA1C4
|
||||
gray_concrete_powder,5C6266
|
||||
light_gray_concrete_powder,BCBCB4
|
||||
cyan_concrete_powder,1AB2C0
|
||||
purple_concrete_powder,9931D8
|
||||
blue_concrete_powder,474BCB
|
||||
brown_concrete_powder,986036
|
||||
green_concrete_powder,73912B
|
||||
red_concrete_powder,CD312C
|
||||
black_concrete_powder,1E1F26
|
||||
dead_tube_coral_block,9F9590
|
||||
dead_brain_coral_block,978E8A
|
||||
dead_bubble_coral_block,A0958F
|
||||
dead_fire_coral_block,A0958F
|
||||
dead_horn_coral_block,A29993
|
||||
tube_coral_block,2559FB
|
||||
brain_coral_block,FD5EBB
|
||||
bubble_coral_block,C90BC5
|
||||
fire_coral_block,C71827
|
||||
horn_coral_block,FFE939
|
||||
sea_pickle,6D7627
|
||||
blue_ice,649EFF
|
||||
bamboo,EBD051
|
||||
scaffolding,CF9A4B
|
||||
loom,AD8E68
|
||||
barrel,A3753C
|
||||
smoker,686562
|
||||
blast_furnace,616163
|
||||
cartography_table,7E684C
|
||||
fletching_table,F0D999
|
||||
lectern,D3A258
|
||||
smithing_table,444555
|
||||
stonecutter,968F86
|
||||
warped_stem,388586
|
||||
stripped_warped_stem,349C97
|
||||
warped_nylium,2A8B79
|
||||
warped_wart_block,0C9194
|
||||
crimson_stem,89334F
|
||||
stripped_crimson_stem,943B5E
|
||||
crimson_nylium,9F1717
|
||||
shroomlight,FF8F35
|
||||
crimson_planks,7B3351
|
||||
warped_planks,2C7F78
|
||||
crimson_trapdoor,7E3553
|
||||
warped_trapdoor,2F9186
|
||||
crimson_door,8B395B
|
||||
warped_door,2A9A92
|
||||
structure_block,6B586E
|
||||
jigsaw,615263
|
||||
target,FFB8A8
|
||||
bee_nest,F6BD48
|
||||
beehive,C0954F
|
||||
honey_block,FFB41D
|
||||
honeycomb_block,FF9A06
|
||||
netherite_block,514A4C
|
||||
ancient_debris,734C41
|
||||
crying_obsidian,230549
|
||||
respawn_anchor,520BB6
|
||||
lodestone,B3B5B9
|
||||
blackstone,332B32
|
||||
polished_blackstone,403944
|
||||
polished_blackstone_bricks,3A323C
|
||||
cracked_polished_blackstone_bricks,362C34
|
||||
chiseled_polished_blackstone,403944
|
||||
gilded_blackstone,43312C
|
||||
chiseled_nether_bricks,39191F
|
||||
cracked_nether_bricks,31151A
|
||||
quartz_bricks,FFF9EF
|
||||
amethyst_block,9A69E9
|
||||
budding_amethyst,9968E3
|
||||
tuff,84857B
|
||||
polished_tuff,757F78
|
||||
chiseled_tuff,71786E
|
||||
tuff_bricks,777C73
|
||||
chiseled_tuff_bricks,868A80
|
||||
calcite,FEFFFA
|
||||
powder_snow,F9FFFF
|
||||
sculk_sensor,005466
|
||||
calibrated_sculk_sensor,165D7A
|
||||
sculk,0D2028
|
||||
sculk_vein,013A47
|
||||
sculk_catalyst,0F252E
|
||||
copper_block,EA7650
|
||||
exposed_copper,C49375
|
||||
weathered_copper,7DBB80
|
||||
oxidized_copper,58C69D
|
||||
copper_ore,979892
|
||||
deepslate_copper_ore,70716C
|
||||
oxidized_cut_copper,56BB96
|
||||
weathered_cut_copper,80B17D
|
||||
exposed_cut_copper,BC8F73
|
||||
cut_copper,E97551
|
||||
oxidized_chiseled_copper,5AC49D
|
||||
weathered_chiseled_copper,78B782
|
||||
exposed_chiseled_copper,BC8C72
|
||||
chiseled_copper,E06E49
|
||||
copper_door,EA7954
|
||||
exposed_copper_door,C89079
|
||||
oxidized_copper_door,59C39D
|
||||
weathered_copper_door,80B77F
|
||||
copper_trapdoor,E87550
|
||||
exposed_copper_trapdoor,C49377
|
||||
oxidized_copper_trapdoor,5AC49D
|
||||
weathered_copper_trapdoor,7DBB80
|
||||
copper_grate,E97650
|
||||
exposed_copper_grate,C49377
|
||||
weathered_copper_grate,7BB980
|
||||
oxidized_copper_grate,58C49B
|
||||
copper_bulb,BE5F37
|
||||
exposed_copper_bulb,A57E66
|
||||
weathered_copper_bulb,6B9A75
|
||||
oxidized_copper_bulb,4CA182
|
||||
dripstone_block,A37F6A
|
||||
azalea,78972E
|
||||
flowering_azalea,879446
|
||||
moss_block,6A852E
|
||||
big_dripleaf,83AC31
|
||||
rooted_dirt,B07853
|
||||
mud,494549
|
||||
deepslate,6A6A6D
|
||||
cobbled_deepslate,5E5E62
|
||||
polished_deepslate,585859
|
||||
deepslate_tiles,424243
|
||||
deepslate_bricks,555557
|
||||
chiseled_deepslate,424242
|
||||
cracked_deepslate_bricks,4E4E4F
|
||||
cracked_deepslate_tiles,3F3F3F
|
||||
smooth_basalt,57575F
|
||||
raw_iron_block,CBA07A
|
||||
raw_copper_block,BC7955
|
||||
raw_gold_block,FFBC1D
|
||||
ochre_froglight,FFF9CD
|
||||
verdant_froglight,EDFFEC
|
||||
pearlescent_froglight,FFF9F8
|
||||
reinforced_deepslate,61645F
|
||||
crafter,897677
|
||||
trial_spawner,2D455D
|
||||
vault,3E5360
|
||||
heavy_core,626873
|
||||
|
@@ -22,7 +22,7 @@ class EssentialsServiceTest {
|
||||
public Pos pos() { return pos; }
|
||||
public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
|
||||
public void teleport(Pos p) { pos = p; }
|
||||
public boolean hasPermission(String n) { return true; }
|
||||
public boolean hasPermission(String n) { return !n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass"); }
|
||||
public long playtimeSeconds() { return 0; }
|
||||
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
|
||||
public void clearHeld() { }
|
||||
|
||||
@@ -17,6 +17,8 @@ final class Kit {
|
||||
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
|
||||
final List<String> inbox = new ArrayList<>();
|
||||
final Set<String> denied = new HashSet<>();
|
||||
/** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */
|
||||
final Set<String> granted = new HashSet<>();
|
||||
Item held;
|
||||
final List<Item> inventory = new ArrayList<>();
|
||||
long playtime = 3700;
|
||||
@@ -26,7 +28,7 @@ final class Kit {
|
||||
public Pos pos() { return pos; }
|
||||
public void send(String m) { inbox.add(Format.plain(m)); }
|
||||
public void teleport(Pos p) { pos = p; }
|
||||
public boolean hasPermission(String n) { return !denied.contains(n); }
|
||||
public boolean hasPermission(String n) { return granted.contains(n) || (!n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass") && !denied.contains(n)); }
|
||||
public long playtimeSeconds() { return playtime; }
|
||||
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
|
||||
public void clearHeld() { held = null; }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LimitsTest {
|
||||
@Test void permissionTiersRaiseALimit() {
|
||||
assertEquals(5, Limits.tier(n -> false, "scopenet.homes.", 5, 500));
|
||||
assertEquals(10, Limits.tier(Set.of("scopenet.homes.10")::contains, "scopenet.homes.", 5, 500));
|
||||
assertEquals(25, Limits.tier(Set.of("scopenet.homes.10", "scopenet.homes.25", "scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "highest wins");
|
||||
assertEquals(5, Limits.tier(Set.of("scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "a lower tier never reduces the base");
|
||||
assertEquals(Integer.MAX_VALUE, Limits.tier(Set.of("scopenet.homes.unlimited")::contains, "scopenet.homes.", 5, 500));
|
||||
assertEquals("unlimited", Limits.show(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test void homesFollowConfigAndPermissions(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(2, 2500, 60_000, 15, 0, 1, 8, Map.of());
|
||||
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, kit.env.rng);
|
||||
svc.setHome(steve, new String[]{"a"});
|
||||
svc.setHome(steve, new String[]{"b"});
|
||||
svc.setHome(steve, new String[]{"c"});
|
||||
assertTrue(steve.last().contains("limit of 2"));
|
||||
steve.granted.add("scopenet.homes.4");
|
||||
svc.setHome(steve, new String[]{"c"});
|
||||
svc.setHome(steve, new String[]{"d"});
|
||||
svc.setHome(steve, new String[]{"e"});
|
||||
assertTrue(steve.last().contains("limit of 4"), steve.last());
|
||||
svc.setHome(steve, new String[]{"waytoolongname"});
|
||||
assertTrue(steve.last().contains("at most 8"), steve.last());
|
||||
svc.setWarp(steve, "spawn");
|
||||
svc.setWarp(steve, "pvp");
|
||||
assertTrue(steve.last().contains("already has 1 warps"), steve.last());
|
||||
}
|
||||
|
||||
@Test void teleportCommandsCoolDownOnlyAfterTeleporting(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(5, 2500, 60_000, 15, 0, 0, 32, Map.of("home", 30, "warp", 10));
|
||||
CommandSet set = new CommandSet(kit.env, cfg);
|
||||
set.essentials.setHome(steve, new String[]{"base"});
|
||||
set.run("home", steve, "nope"); // unknown home: nothing happened, no cooldown
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), steve.last());
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Please wait 30s"), steve.last());
|
||||
kit.now.addAndGet(31_000);
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), steve.last());
|
||||
set.run("warp", steve, "missing");
|
||||
set.run("warp", steve, "missing");
|
||||
assertFalse(steve.last().contains("Please wait"), "a failed warp costs nothing");
|
||||
steve.granted.add("scopenet.cooldown.bypass");
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), "bypass skips cooldowns");
|
||||
}
|
||||
|
||||
@Test void rtpKeepsAwayFromTheCentre(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(5, 400, 60_000, 50, 300, 0, 32, Map.of());
|
||||
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, new java.util.Random(7));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
svc.rtp(steve);
|
||||
int x = (int) steve.pos.x(), z = (int) steve.pos.z();
|
||||
assertTrue(Math.max(Math.abs(x), Math.abs(z)) >= 300, "landed at " + x + "," + z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.map.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapOverlayTest {
|
||||
@Test void overlayCarriesShapesAndPlainText() {
|
||||
var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16)));
|
||||
var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron <Wolves>", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8,
|
||||
"<div class=\"scopenet-detail\"><strong>Iron <Wolves></strong> <span>[IRN]</span><br>1 claimed chunk in this area</div>");
|
||||
var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6,
|
||||
"<div class=\"scopenet-detail\"><strong>Warp</strong> a<br><code>/warp a</code></div>");
|
||||
JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of()));
|
||||
JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject();
|
||||
assertEquals("#3366cc", c.get("color").getAsString());
|
||||
assertEquals(4, c.getAsJsonArray("outer").size());
|
||||
assertEquals("Iron <Wolves> [IRN]", c.getAsJsonArray("lines").get(0).getAsString());
|
||||
assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString());
|
||||
JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject();
|
||||
assertEquals("warp", p.get("kind").getAsString());
|
||||
assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString());
|
||||
assertFalse(json.toString().contains("<div"), "no markup reaches the viewer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RewardPollerTest {
|
||||
/** A platform that records what was given and run. */
|
||||
static final class Recording implements Platform {
|
||||
final Kit.FakePlatform inner;
|
||||
final List<String> given = new ArrayList<>(), ran = new ArrayList<>();
|
||||
boolean knowsItems = true, knowsCommands = true;
|
||||
Recording(Kit.FakePlatform inner) { this.inner = inner; }
|
||||
public java.util.Optional<CorePlayer> player(java.util.UUID u) { return inner.player(u); }
|
||||
public java.util.Optional<CorePlayer> playerByName(String n) { return inner.playerByName(n); }
|
||||
public java.util.Collection<? extends CorePlayer> online() { return inner.online(); }
|
||||
public java.util.Optional<Pos> safeSurface(String w, int x, int z) { return inner.safeSurface(w, x, z); }
|
||||
public Pos worldSpawn(String w) { return inner.worldSpawn(w); }
|
||||
public void runMain(Runnable r) { r.run(); }
|
||||
public void runAsync(Runnable r) { r.run(); }
|
||||
@Override public boolean giveItem(java.util.UUID p, String item, int amount) { if (knowsItems) given.add(item + "x" + amount); return knowsItems; }
|
||||
@Override public boolean console(String c) { if (knowsCommands) ran.add(c); return knowsCommands; }
|
||||
}
|
||||
|
||||
private static JsonObject delivery(long id, String uuid, String kind, String payload) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", id);
|
||||
o.addProperty("uuid", uuid);
|
||||
o.addProperty("kind", kind);
|
||||
o.add("payload", JsonParser.parseString(payload));
|
||||
return o;
|
||||
}
|
||||
|
||||
@Test void rewardsAreCarriedOutAndReported(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
Recording platform = new Recording(kit.platform);
|
||||
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
|
||||
JsonObject[] ack = new JsonObject[1];
|
||||
kit.panel.handlers.put("rewards/ack", b -> { ack[0] = b; return new JsonObject(); });
|
||||
kit.panel.handlers.put("rewards/poll", b -> {
|
||||
JsonArray list = new JsonArray();
|
||||
String id = steve.id.toString();
|
||||
list.add(delivery(1, id, "item", "{\"item\":\"minecraft:diamond\",\"amount\":3}"));
|
||||
list.add(delivery(2, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":0}"));
|
||||
list.add(delivery(3, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":10080}"));
|
||||
list.add(delivery(4, id, "group", "{\"group\":\"vip\"}"));
|
||||
list.add(delivery(5, id, "command", "{\"command\":\"give {player} cake 1\"}"));
|
||||
list.add(delivery(6, id, "message", "{\"text\":\"Well done!\"}"));
|
||||
JsonObject o = new JsonObject();
|
||||
o.add("deliveries", list);
|
||||
return o;
|
||||
});
|
||||
RewardPoller poller = new RewardPoller(env, RewardRules.defaults());
|
||||
poller.tick();
|
||||
assertEquals(List.of("minecraft:diamondx3"), platform.given);
|
||||
assertEquals(List.of("lp user Steve permission set essentials.fly true", "lp user Steve permission settemp essentials.fly true 10080m",
|
||||
"lp user Steve parent add vip"), platform.ran, "custom commands are off by default");
|
||||
assertEquals(5, ack[0].getAsJsonArray("done").size());
|
||||
assertEquals(1, ack[0].getAsJsonArray("failed").size());
|
||||
assertTrue(ack[0].getAsJsonArray("failed").get(0).getAsJsonObject().get("error").getAsString().contains("allow-commands"));
|
||||
assertTrue(steve.heard("Well done!"));
|
||||
assertTrue(steve.heard("3x Diamond"));
|
||||
}
|
||||
|
||||
@Test void commandsRunWhenTheOwnerAllowsThem(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
Recording platform = new Recording(kit.platform);
|
||||
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
|
||||
RewardRules r = RewardRules.defaults();
|
||||
RewardPoller poller = new RewardPoller(env, new RewardRules(true, true, r.permissionCommand(), r.permissionDenyCommand(), r.permissionTempCommand(), r.groupCommand()));
|
||||
assertNull(poller.apply(delivery(1, steve.id.toString(), "command", "{\"command\":\"give {player} cake 1\"}")));
|
||||
assertEquals(List.of("give Steve cake 1"), platform.ran);
|
||||
platform.knowsItems = false;
|
||||
assertTrue(poller.apply(delivery(2, steve.id.toString(), "item", "{\"item\":\"mod:thing\",\"amount\":1}")).contains("unknown item"));
|
||||
kit.platform.online.clear();
|
||||
assertEquals("player left", poller.apply(delivery(3, steve.id.toString(), "message", "{\"text\":\"hi\"}")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapRenderTest {
|
||||
// ---- a tiny NBT writer for building chunks ----
|
||||
|
||||
static final class W {
|
||||
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
final DataOutputStream out = new DataOutputStream(bytes);
|
||||
|
||||
W compound(String name) throws IOException { out.writeByte(10); out.writeUTF(name); return this; }
|
||||
W end() throws IOException { out.writeByte(0); return this; }
|
||||
W string(String name, String v) throws IOException { out.writeByte(8); out.writeUTF(name); out.writeUTF(v); return this; }
|
||||
W i(String name, int v) throws IOException { out.writeByte(3); out.writeUTF(name); out.writeInt(v); return this; }
|
||||
W b(String name, int v) throws IOException { out.writeByte(1); out.writeUTF(name); out.writeByte(v); return this; }
|
||||
W list(String name, int type, int n) throws IOException { out.writeByte(9); out.writeUTF(name); out.writeByte(type); out.writeInt(n); return this; }
|
||||
W longs(String name, long[] v) throws IOException {
|
||||
out.writeByte(12); out.writeUTF(name); out.writeInt(v.length);
|
||||
for (long l : v) out.writeLong(l);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** A chunk with one section (y = sectionY) whose blocks come from {@code blockAt}(x,y,z) -> palette index. */
|
||||
static byte[] chunk(int sectionY, List<String> palette, TriFunction blockAt) throws IOException {
|
||||
W w = new W().compound("");
|
||||
w.string("Status", "minecraft:full");
|
||||
w.list("sections", 10, 1);
|
||||
w.b("Y", sectionY);
|
||||
w.compound("block_states");
|
||||
w.list("palette", 10, palette.size());
|
||||
for (String name : palette) w.string("Name", name).end();
|
||||
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1));
|
||||
int per = 64 / bits;
|
||||
long[] data = new long[(4096 + per - 1) / per];
|
||||
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
|
||||
int idx = y << 8 | z << 4 | x;
|
||||
data[idx / per] |= (long) blockAt.apply(x, y, z) << (idx % per * bits);
|
||||
}
|
||||
w.longs("data", data);
|
||||
w.end(); // block_states
|
||||
w.end(); // section
|
||||
w.end(); // root
|
||||
return w.bytes.toByteArray();
|
||||
}
|
||||
|
||||
interface TriFunction { int apply(int x, int y, int z); }
|
||||
|
||||
/** Writes a region file with the given chunks at (cx, cz) -> NBT bytes, zlib compressed. */
|
||||
static Path region(Path dir, Map<Integer, byte[]> chunks) throws IOException {
|
||||
Path file = dir.resolve("r.0.0.mca");
|
||||
ByteBuffer header = ByteBuffer.allocate(8192);
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
int sector = 2;
|
||||
for (var e : chunks.entrySet()) {
|
||||
ByteArrayOutputStream z = new ByteArrayOutputStream();
|
||||
try (DeflaterOutputStream d = new DeflaterOutputStream(z, new Deflater())) { d.write(e.getValue()); }
|
||||
byte[] comp = z.toByteArray();
|
||||
int length = comp.length + 1;
|
||||
int sectors = (length + 4 + 4095) / 4096;
|
||||
header.putInt(e.getKey() * 4, sector << 8 | sectors);
|
||||
header.putInt(4096 + e.getKey() * 4, 1_700_000_000);
|
||||
ByteBuffer rec = ByteBuffer.allocate(sectors * 4096);
|
||||
rec.putInt(length).put((byte) 2).put(comp);
|
||||
body.writeBytes(rec.array());
|
||||
sector += sectors;
|
||||
}
|
||||
Files.createDirectories(dir);
|
||||
try (OutputStream out = Files.newOutputStream(file)) {
|
||||
out.write(header.array());
|
||||
out.write(body.toByteArray());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
@Test void blockNamesBecomeMapColours() {
|
||||
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:air"));
|
||||
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:glass"));
|
||||
assertEquals(MapColors.GRASS, BlockColors.mapColor("minecraft:grass_block"));
|
||||
assertEquals(MapColors.WATER, BlockColors.mapColor("minecraft:water"));
|
||||
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:stone"));
|
||||
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:mossy_cobblestone_stairs"));
|
||||
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_planks"));
|
||||
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_stairs"));
|
||||
assertEquals(MapColors.PLANT, BlockColors.mapColor("minecraft:oak_leaves"));
|
||||
assertEquals(MapColors.SAND, BlockColors.mapColor("minecraft:cut_sandstone_slab"));
|
||||
assertEquals(MapColors.NETHER, BlockColors.mapColor("minecraft:netherrack"));
|
||||
assertEquals(MapColors.RED, BlockColors.mapColor("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1);
|
||||
assertEquals(MapColors.LIGHT_BLUE, BlockColors.mapColor("minecraft:light_blue_concrete"));
|
||||
assertEquals(MapColors.DEEPSLATE, BlockColors.mapColor("minecraft:deepslate_iron_ore"));
|
||||
assertEquals(MapColors.FIRE, BlockColors.mapColor("minecraft:lava"));
|
||||
assertEquals(0, BlockColors.of("minecraft:air"));
|
||||
assertEquals(BlockColors.WATER_COLOR, BlockColors.of("minecraft:water"));
|
||||
int grass = BlockColors.of("minecraft:grass_block");
|
||||
assertTrue((grass >> 8 & 0xFF) > (grass >> 16 & 0xFF) && (grass >> 8 & 0xFF) > (grass & 0xFF), "grass is green");
|
||||
assertEquals(BlockColors.of("minecraft:oak_planks"), BlockColors.of("minecraft:oak_stairs"), "shapes share their material's colour");
|
||||
assertTrue(BlockColors.of("create:brass_casing") != 0, "modded blocks still get a colour");
|
||||
int modded = BlockColors.mapColor("create:brass_casing");
|
||||
assertEquals(modded, BlockColors.mapColor("create:brass_casing"), "stable for unknown blocks");
|
||||
assertTrue(modded > 0);
|
||||
}
|
||||
|
||||
@Test void nbtAndRegionFilesRoundTrip(@TempDir Path dir) throws IOException {
|
||||
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone"), (x, y, z) -> y < 8 ? 1 : 0);
|
||||
Path file = region(dir, Map.of(RegionFile.index(3, 5), nbt));
|
||||
try (RegionFile r = new RegionFile(file)) {
|
||||
assertEquals(1_700_000_000, r.timestamp(RegionFile.index(3, 5)));
|
||||
assertEquals(0, r.timestamp(RegionFile.index(0, 0)));
|
||||
assertNull(r.read(RegionFile.index(0, 0)));
|
||||
Nbt.Compound c = r.read(RegionFile.index(3, 5));
|
||||
assertNotNull(c);
|
||||
assertEquals("minecraft:full", c.string("Status", ""));
|
||||
assertEquals(1, c.list("sections").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void topSurfaceAndWaterDepthAreFound() throws IOException {
|
||||
// y 0..9 stone, 10..12 water in the left half.
|
||||
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone", "minecraft:water"),
|
||||
(x, y, z) -> y < 6 ? 1 : (x < 8 && y < 9) ? 2 : y < 6 ? 1 : 0);
|
||||
Nbt.Compound c = new Nbt.Compound();
|
||||
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols));
|
||||
assertEquals(BlockColors.of("minecraft:stone"), cols.color[15]);
|
||||
assertEquals(4 * 16 + 5, cols.height[15]);
|
||||
assertEquals(BlockColors.WATER_COLOR, cols.color[0]);
|
||||
assertEquals(4 * 16 + 8, cols.height[0]);
|
||||
assertEquals(3, cols.depth[0]);
|
||||
}
|
||||
|
||||
@Test void theNetherIsShownBeneathItsCeiling() throws IOException {
|
||||
// Section 7 covers y 112..127: bedrock up top (y 124..127 -> local 12..15), open air below, floor of netherrack at local y 3.
|
||||
byte[] nbt = chunk(7, List.of("minecraft:air", "minecraft:bedrock", "minecraft:netherrack"),
|
||||
(x, y, z) -> y >= 12 ? 1 : y <= 3 ? 2 : 0);
|
||||
Nbt.Compound c;
|
||||
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols));
|
||||
assertEquals(BlockColors.of("minecraft:netherrack"), cols.color[0], "the floor, not the bedrock roof");
|
||||
assertEquals(7 * 16 + 3, cols.height[0]);
|
||||
}
|
||||
|
||||
@Test void regionsRenderIntoPositionedTiles(@TempDir Path dir) throws IOException {
|
||||
byte[] flat = chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
|
||||
// chunk (17, 2): second tile column (chunk x 16..31), first tile row
|
||||
Path file = region(dir, Map.of(RegionFile.index(17, 2), flat));
|
||||
int[][] tiles = RegionRenderer.render(file, ChunkSampler.Kind.OVERWORLD);
|
||||
assertNotNull(tiles);
|
||||
int[] right = tiles[1];
|
||||
int px = (17 - 16) * 16 + 4, py = 2 * 16 + 4; // inside the chunk
|
||||
assertEquals(0xFF, right[py * 256 + px] >>> 24);
|
||||
int green = right[py * 256 + px];
|
||||
assertTrue((green >> 8 & 0xFF) > (green >> 16 & 0xFF), "grass reads as green");
|
||||
assertEquals(0, tiles[0][py * 256 + px], "other tiles stay empty");
|
||||
assertEquals(0, right[0], "outside the chunk is transparent");
|
||||
}
|
||||
|
||||
@Test void slopesAreShadedAgainstTheNorth() {
|
||||
int n = 512;
|
||||
int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n];
|
||||
Arrays.fill(colors, BlockColors.of("minecraft:stone"));
|
||||
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3);
|
||||
int[] px = RegionRenderer.shade(colors, heights, depths);
|
||||
int uphill = px[16 * n + 3]; // a step up from the north
|
||||
int flat = px[17 * n + 3]; // same height as its north neighbour
|
||||
assertTrue(brightness(uphill) >= brightness(flat));
|
||||
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 200 - z / 8 * 3);
|
||||
int[] down = RegionRenderer.shade(colors, heights, depths);
|
||||
assertTrue(brightness(down[16 * n + 3]) <= brightness(down[17 * n + 3]));
|
||||
}
|
||||
|
||||
private static int brightness(int argb) { return (argb >> 16 & 0xFF) + (argb >> 8 & 0xFF) + (argb & 0xFF); }
|
||||
|
||||
@Test void pngIsAValidImage() throws IOException {
|
||||
int[] pixels = new int[256 * 256];
|
||||
for (int i = 0; i < pixels.length; i++) pixels[i] = (i % 256 < 128) ? 0xFF336699 : 0;
|
||||
byte[] png = Png.encode(pixels, 256, 256);
|
||||
var image = ImageIO.read(new ByteArrayInputStream(png));
|
||||
assertEquals(256, image.getWidth());
|
||||
assertEquals(0xFF336699, image.getRGB(10, 10));
|
||||
assertEquals(0, image.getRGB(200, 10) >>> 24);
|
||||
assertTrue(png.length < 400, "a two-colour tile is tiny: " + png.length);
|
||||
}
|
||||
|
||||
@Test void realisticTilesStaySmall(@TempDir Path dir) throws IOException {
|
||||
byte[] hills = chunk(0, List.of("minecraft:air", "minecraft:grass_block", "minecraft:stone", "minecraft:water"),
|
||||
(x, y, z) -> y < 3 + (x * 7 + z * 3) % 5 ? 1 : y < 9 && x > 8 ? 3 : 0);
|
||||
Map<Integer, byte[]> chunks = new HashMap<>();
|
||||
for (int i = 0; i < 256; i++) chunks.put(i, hills);
|
||||
int[][] tiles = RegionRenderer.render(region(dir, chunks), ChunkSampler.Kind.OVERWORLD);
|
||||
byte[] png = Png.encode(tiles[0], 256, 256);
|
||||
byte[] rgba = Png.encode(tiles[0], 256, 256);
|
||||
assertTrue(png.length < 20_000, "palette PNG size " + png.length);
|
||||
var image = ImageIO.read(new ByteArrayInputStream(png));
|
||||
for (int i = 0; i < 256 * 256; i += 997) assertEquals(tiles[0][i], image.getRGB(i % 256, i / 256), "pixel " + i);
|
||||
}
|
||||
|
||||
@Test void zoomLevelsAreBuiltFromTheTilesBelow(@TempDir Path dir) throws IOException {
|
||||
TileStore store = new TileStore(dir);
|
||||
int[] red = new int[256 * 256];
|
||||
Arrays.fill(red, 0xFFFF0000);
|
||||
TileStore.Key k = new TileStore.Key("overworld", 0, 3, -2);
|
||||
assertTrue(store.update(k, red));
|
||||
assertFalse(store.update(k, red.clone()), "an identical tile is not a change");
|
||||
TileStore.Key parent = store.rebuildParent(k);
|
||||
assertEquals(new TileStore.Key("overworld", 1, 1, -1), parent);
|
||||
int[] p = store.load(parent);
|
||||
// child (3,-2) is the lower-right?? x=3 odd -> right, y=-2 even -> top: quadrant (1,0)
|
||||
assertEquals(0xFFFF0000, p[10 * 256 + 200]);
|
||||
assertEquals(0, p[10 * 256 + 10]);
|
||||
assertEquals(parent, TileStore.Key.parse(parent.toString()));
|
||||
// a fresh store sees the cached tile
|
||||
assertArrayEquals(red, new TileStore(dir).load(k));
|
||||
store.clear();
|
||||
assertNull(new TileStore(dir).load(k));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import net.scopenet.integration.PanelClient;
|
||||
import net.scopenet.integration.Settings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** A fake panel and a generated world: tiles, players and the overlay all arrive, and only once. */
|
||||
class WorldMapSyncTest {
|
||||
@Test void worldsAreRenderedAndUploaded(@TempDir Path dir) throws Exception {
|
||||
byte[] flat = MapRenderTest.chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
|
||||
MapRenderTest.region(dir.resolve("world/region"), Map.of(RegionFile.index(1, 1), flat, RegionFile.index(20, 3), flat));
|
||||
|
||||
List<String> tiles = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> players = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> overlays = new CopyOnWriteArrayList<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/server/v1/map/config", ex -> reply(ex, "{\"enabled\":true,\"epoch\":3,\"max_zoom\":2,\"player_interval_ms\":500}"));
|
||||
server.createContext("/api/server/v1/map/tiles", ex -> {
|
||||
assertEquals("dim=minecraft:overworld", ex.getRequestURI().getRawQuery());
|
||||
ByteBuffer body = ByteBuffer.wrap(ex.getRequestBody().readAllBytes());
|
||||
while (body.hasRemaining()) {
|
||||
int zoom = body.get() & 0xFF, x = body.getInt(), y = body.getInt(), len = body.getInt();
|
||||
byte[] png = new byte[len];
|
||||
body.get(png);
|
||||
assertEquals((byte) 0x89, png[0]);
|
||||
tiles.add(zoom + "/" + x + "/" + y);
|
||||
}
|
||||
reply(ex, "{\"stored\":1}");
|
||||
});
|
||||
server.createContext("/api/server/v1/map/players", ex -> { players.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.createContext("/api/server/v1/map/overlay", ex -> { overlays.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.start();
|
||||
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
|
||||
WorldMapSync sync = new WorldMapSync(client, dir.resolve("world"), dir.resolve("cache"), System.err::println);
|
||||
sync.setOverlay(() -> "{\"claims\":[],\"pins\":[{\"id\":\"spawn\"}]}");
|
||||
sync.offerPlayers(List.of(new WorldMapSync.Player("11111111-1111-1111-1111-111111111111", "Steve", "minecraft:overworld", 10.5, 64, 20, 90, 0)));
|
||||
try {
|
||||
sync.start();
|
||||
long deadline = System.currentTimeMillis() + 20_000;
|
||||
while ((tiles.size() < 4 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100);
|
||||
// chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them
|
||||
assertTrue(tiles.contains("0/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("0/1/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("1/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("2/0/0"), tiles.toString());
|
||||
assertEquals("Steve", players.get(0).getAsJsonArray("players").get(0).getAsJsonObject().get("name").getAsString());
|
||||
assertEquals("spawn", overlays.get(0).getAsJsonArray("pins").get(0).getAsJsonObject().get("id").getAsString());
|
||||
int sent = tiles.size();
|
||||
Thread.sleep(1500);
|
||||
assertEquals(sent, tiles.size(), "an unchanged world isn't uploaded again");
|
||||
} finally {
|
||||
sync.close();
|
||||
server.stop(0);
|
||||
}
|
||||
// the state survives a restart: a new sync with the same cache sends nothing
|
||||
assertTrue(java.nio.file.Files.isRegularFile(dir.resolve("cache/state.json")));
|
||||
}
|
||||
|
||||
private static void reply(com.sun.net.httpserver.HttpExchange ex, String json) throws java.io.IOException {
|
||||
byte[] b = json.getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, b.length);
|
||||
ex.getResponseBody().write(b);
|
||||
ex.close();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ dependencies {
|
||||
implementation project(':common')
|
||||
if (features) {
|
||||
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
|
||||
implementation project(':bluemap')
|
||||
// Optional at runtime: present only when the LuckPerms mod is installed.
|
||||
compileOnly 'net.luckperms:api:5.4'
|
||||
}
|
||||
@@ -32,6 +31,5 @@ processResources {
|
||||
jar {
|
||||
archiveBaseName = "scopenet-fabric-${mc}"
|
||||
from project(':common').sourceSets.main.output
|
||||
if (features) from project(':bluemap').sourceSets.main.output
|
||||
}
|
||||
base.archivesName = "scopenet-fabric-${mc}"
|
||||
@@ -7,5 +7,5 @@
|
||||
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
|
||||
"mixins": ["scopenet.mixins.json"],
|
||||
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
|
||||
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" }
|
||||
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package net.scopenet.fabric.features;
|
||||
|
||||
import net.scopenet.bluemap.MapAddon;
|
||||
import net.scopenet.core.map.MapData;
|
||||
import net.scopenet.core.map.MapService;
|
||||
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
|
||||
final class BlueMapHook {
|
||||
private BlueMapHook() {}
|
||||
|
||||
/**
|
||||
* Registered while the mod initialises (BlueMap reads its script list when it starts), so the data comes from
|
||||
* {@code service}, which only exists once the server is running: until then the map is simply empty.
|
||||
*/
|
||||
static Object start(java.util.function.Supplier<MapService> service, Logger log) {
|
||||
return MapAddon.start(new MapAddon.Feed() {
|
||||
@Override public MapData snapshot() { MapService s = service.get(); return s == null ? MapData.empty() : s.snapshot(); }
|
||||
@Override public com.google.gson.JsonObject playersJson() {
|
||||
MapService s = service.get();
|
||||
return s == null ? net.scopenet.core.map.MapModel.playersJson("", java.util.List.of(), System.currentTimeMillis()) : s.playersJson();
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
static void stop(Object handle) { ((MapAddon) handle).stop(); }
|
||||
}
|
||||
@@ -33,8 +33,12 @@ final class FabricConfig {
|
||||
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
|
||||
|
||||
EssentialsConfig essentials() {
|
||||
java.util.Map<String, Integer> cooldowns = new java.util.HashMap<>();
|
||||
for (String cmd : new String[]{"spawn", "home", "back", "tpa", "rtp", "warp"}) cooldowns.put(cmd, Math.max(0, integer("essentials.cooldown." + cmd, 0)));
|
||||
return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)),
|
||||
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15);
|
||||
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, Math.max(1, integer("essentials.rtp_attempts", 15)),
|
||||
Math.max(0, integer("essentials.rtp_min_radius", 0)), Math.max(0, integer("essentials.max_warps", 0)),
|
||||
Math.max(1, integer("essentials.home_name_max_length", 32)), cooldowns);
|
||||
}
|
||||
|
||||
Features features(Settings s) {
|
||||
@@ -42,6 +46,13 @@ final class FabricConfig {
|
||||
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
|
||||
}
|
||||
|
||||
net.scopenet.core.RewardRules rewards() {
|
||||
net.scopenet.core.RewardRules d = net.scopenet.core.RewardRules.defaults();
|
||||
return new net.scopenet.core.RewardRules(bool("rewards.enabled", true), bool("rewards.allow_commands", false),
|
||||
text("rewards.permission_command", d.permissionCommand()), text("rewards.permission_deny_command", d.permissionDenyCommand()),
|
||||
text("rewards.permission_temp_command", d.permissionTempCommand()), text("rewards.group_command", d.groupCommand()));
|
||||
}
|
||||
|
||||
/** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */
|
||||
boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); }
|
||||
}
|
||||
@@ -55,18 +55,14 @@ public final class FabricFeatures implements Runnable {
|
||||
private Perms perms;
|
||||
private FabricConfig config;
|
||||
private ActionPoller poller;
|
||||
private net.scopenet.core.RewardPoller rewards;
|
||||
private MapService map;
|
||||
private Object blueMap;
|
||||
private int ticks;
|
||||
private String placeholderStatus = "off";
|
||||
|
||||
CommandSet commandSet() { return commands; }
|
||||
|
||||
@Override public void run() {
|
||||
// Hook into BlueMap early: it reads the list of web scripts when it starts.
|
||||
if (FabricConfig.load().bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) {
|
||||
try { blueMap = BlueMapHook.start(() -> map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); }
|
||||
}
|
||||
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
|
||||
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
|
||||
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
|
||||
@@ -116,10 +112,12 @@ public final class FabricFeatures implements Runnable {
|
||||
integration.onNotifications(events -> link.onPanelEvents(events));
|
||||
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
|
||||
|
||||
// Actions from the map, and the BlueMap addon (claims, pins, player panel) when BlueMap is installed.
|
||||
// Actions from the map, and the claims and pins the SCOPENET Map draws.
|
||||
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
|
||||
rewards = new net.scopenet.core.RewardPoller(env, config.rewards());
|
||||
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
|
||||
config.bool("bluemap.show_homes", false), s.getMotd());
|
||||
config.bool("map.show_homes", false), s.getMotd());
|
||||
integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); });
|
||||
registerCommands(s.getCommands().getDispatcher());
|
||||
registerPlaceholders();
|
||||
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
|
||||
@@ -130,7 +128,6 @@ public final class FabricFeatures implements Runnable {
|
||||
private void stopping() {
|
||||
ScopenetEconomy.uninstall();
|
||||
Bridge.claimBypass = p -> false;
|
||||
if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; }
|
||||
if (platform != null) platform.shutdown();
|
||||
commands = null;
|
||||
map = null;
|
||||
@@ -155,6 +152,7 @@ public final class FabricFeatures implements Runnable {
|
||||
cache.tick(p -> link.pushState(p));
|
||||
link.tick();
|
||||
poller.tick();
|
||||
rewards.tick();
|
||||
map.tick();
|
||||
}
|
||||
if (ticks % 40 == 0) commands.tick();
|
||||
|
||||
@@ -72,4 +72,26 @@ final class FabricPlatform implements Platform {
|
||||
@Override public void runMain(Runnable task) { server.execute(task); }
|
||||
|
||||
@Override public void runAsync(Runnable task) { io.execute(task); }
|
||||
|
||||
@Override public boolean giveItem(UUID id, String item, int amount) {
|
||||
ServerPlayer p = server.getPlayerList().getPlayer(id);
|
||||
ResourceLocation location = ResourceLocation.tryParse(item);
|
||||
if (p == null || location == null) return false;
|
||||
var found = net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(location);
|
||||
if (found.isEmpty() || found.get() == net.minecraft.world.item.Items.AIR) return false;
|
||||
net.minecraft.world.item.Item type = found.get();
|
||||
for (int left = amount; left > 0; ) {
|
||||
int n = Math.min(left, type.getMaxStackSize());
|
||||
net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(type, n);
|
||||
p.getInventory().add(stack);
|
||||
if (!stack.isEmpty()) p.drop(stack, false);
|
||||
left -= n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public boolean console(String command) {
|
||||
server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,11 @@ final class Perms {
|
||||
|
||||
static boolean luckPermsPresent() { return LUCKPERMS; }
|
||||
|
||||
/** Nodes that grant extras (limit tiers like scopenet.homes.10): nobody has them unless a permissions system says so. */
|
||||
static boolean isGrantNode(String node) { return node.startsWith("scopenet.homes.") || node.startsWith("scopenet.limit."); }
|
||||
|
||||
static boolean isAdminNode(String node) {
|
||||
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
|
||||
return node.equals("scopenet.claims.bypass") || node.equals("scopenet.cooldown.bypass") || node.startsWith("scopenet.admin")
|
||||
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ final class Perms {
|
||||
// LuckPerms not ready yet or an API change: fall back to the defaults below.
|
||||
}
|
||||
}
|
||||
if (isGrantNode(node)) return false;
|
||||
if (isAdminNode(node)) return player.hasPermissions(2);
|
||||
return everyoneByDefault || player.hasPermissions(2);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public final class Bridge {
|
||||
}));
|
||||
integration.startClaimSync();
|
||||
Path config = Path.of("config/scopenet.properties");
|
||||
if (Settings.liveMapEnabled(config)) integration.enableLiveMap();
|
||||
if (Settings.mapEnabled(config)) integration.enableMap(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT), Path.of("config/scopenet/map"));
|
||||
} catch (Exception e) {
|
||||
// Throwing aborts startup; silently disabling an auth mod is unsafe.
|
||||
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
|
||||
@@ -71,13 +71,13 @@ public final class Bridge {
|
||||
current.tick();
|
||||
if (++mapTicks >= 20) {
|
||||
mapTicks = 0;
|
||||
List<LiveMapSync.Player> positions = new ArrayList<>();
|
||||
List<net.scopenet.worldmap.WorldMapSync.Player> positions = new ArrayList<>();
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
|
||||
positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
|
||||
Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0,
|
||||
Math.round(player.getYRot()), Math.round(player.getXRot())));
|
||||
}
|
||||
current.liveMapPlayers(positions);
|
||||
current.mapPlayers(positions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ repositories {
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
implementation project(':api')
|
||||
implementation project(':bluemap')
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
|
||||
// Optional integrations: the plugin detects these at runtime and works without them.
|
||||
@@ -23,7 +22,6 @@ jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from project(':common').sourceSets.main.output
|
||||
from project(':api').sourceSets.main.output
|
||||
from project(':bluemap').sourceSets.main.output
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') }.collect { zipTree(it) } }
|
||||
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
|
||||
}
|
||||
@@ -30,8 +30,8 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
private final Integration integration;
|
||||
private final ShopPoints shops;
|
||||
private ActionPoller poller;
|
||||
private net.scopenet.core.RewardPoller rewards;
|
||||
private MapService mapService;
|
||||
private Object blueMap;
|
||||
private EconomyHandler economy;
|
||||
|
||||
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
|
||||
@@ -64,7 +64,7 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop points, actions from the map, and the BlueMap addon. Called once the command handlers exist; any of them may be
|
||||
* Shop points, actions from the map, and the claims and pins for the SCOPENET Map. Called once the command handlers exist; any of them may be
|
||||
* null when that system is switched off.
|
||||
*/
|
||||
void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) {
|
||||
@@ -81,11 +81,14 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
|
||||
};
|
||||
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
|
||||
plugin.getConfig().getBoolean("bluemap.show-homes", false), Bukkit.getServer().getName());
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20);
|
||||
if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) {
|
||||
blueMap = BlueMapHook.start(mapService, plugin.getLogger());
|
||||
}
|
||||
plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName());
|
||||
integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString());
|
||||
var cfg = plugin.getConfig();
|
||||
var defaults = net.scopenet.core.RewardRules.defaults();
|
||||
rewards = new net.scopenet.core.RewardPoller(env, new net.scopenet.core.RewardRules(cfg.getBoolean("rewards.enabled", true), cfg.getBoolean("rewards.allow-commands", false),
|
||||
cfg.getString("rewards.permission-command", defaults.permissionCommand()), cfg.getString("rewards.permission-deny-command", defaults.permissionDenyCommand()),
|
||||
cfg.getString("rewards.permission-temp-command", defaults.permissionTempCommand()), cfg.getString("rewards.group-command", defaults.groupCommand())));
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); rewards.tick(); mapService.tick(); }, 60, 20);
|
||||
}
|
||||
|
||||
/** /market point add|remove|list, on the block the player is looking at (or standing on). */
|
||||
@@ -113,20 +116,7 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
|
||||
private static final class BlueMapHook {
|
||||
static Object start(MapService service, Logger log) {
|
||||
return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() {
|
||||
@Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); }
|
||||
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
|
||||
}, log);
|
||||
}
|
||||
|
||||
static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); }
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; }
|
||||
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
|
||||
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
|
||||
}
|
||||
@@ -166,6 +156,22 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
}
|
||||
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
|
||||
|
||||
@Override public boolean giveItem(UUID id, String item, int amount) {
|
||||
Player p = Bukkit.getPlayer(id);
|
||||
if (p == null) return false;
|
||||
Material m = Material.matchMaterial(item);
|
||||
if (m == null && item.startsWith("minecraft:")) m = Material.matchMaterial(item.substring(10));
|
||||
if (m == null || !m.isItem() || m == Material.AIR) return false;
|
||||
for (int left = amount; left > 0; ) {
|
||||
int n = Math.min(left, m.getMaxStackSize());
|
||||
for (ItemStack over : p.getInventory().addItem(new ItemStack(m, n)).values()) p.getWorld().dropItemNaturally(p.getLocation(), over);
|
||||
left -= n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public boolean console(String command) { return Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); }
|
||||
|
||||
record PaperPlayer(Player p) implements CorePlayer {
|
||||
@Override public UUID uuid() { return p.getUniqueId(); }
|
||||
@Override public String name() { return p.getName(); }
|
||||
|
||||
@@ -51,10 +51,19 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
// Claim protection runs from a local copy of the panel's claims, refreshed in the background.
|
||||
integration.startClaimSync();
|
||||
|
||||
// Live positions center the in-app BlueMap claim view on the player.
|
||||
if (getConfig().getBoolean("livemap.enabled", true)) {
|
||||
integration.enableLiveMap();
|
||||
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
|
||||
// The SCOPENET Map: renders the world to tiles and sends them, positions, claims and pins to the panel.
|
||||
// Started on the first tick so the worlds exist.
|
||||
if (getConfig().getBoolean("map.enabled", getConfig().getBoolean("livemap.enabled", true))) {
|
||||
Bukkit.getScheduler().runTask(this, () -> {
|
||||
if (integration == null || Bukkit.getWorlds().isEmpty()) return;
|
||||
integration.enableMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), getDataFolder().toPath().resolve("map"));
|
||||
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
|
||||
});
|
||||
getServer().getPluginManager().registerEvents(new org.bukkit.event.Listener() {
|
||||
@org.bukkit.event.EventHandler public void onSave(org.bukkit.event.world.WorldSaveEvent event) {
|
||||
if (integration != null) integration.mapSaved();
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
|
||||
@@ -78,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
|
||||
{
|
||||
@@ -114,7 +124,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
bindCommand("autoclaim", guilds);
|
||||
}
|
||||
|
||||
// Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it.
|
||||
// Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws.
|
||||
clientLink.startMap(essentialsHandler, guildHandler, economy);
|
||||
|
||||
// Reloading an auth plugin cannot silently admit existing sessions.
|
||||
@@ -160,7 +170,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
|
||||
private void snapshotPlayers() {
|
||||
if (integration == null) return;
|
||||
java.util.List<LiveMapSync.Player> list = new java.util.ArrayList<>();
|
||||
java.util.List<net.scopenet.worldmap.WorldMapSync.Player> list = new java.util.ArrayList<>();
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
Location at = p.getLocation();
|
||||
String dim = switch (at.getWorld().getEnvironment()) {
|
||||
@@ -169,11 +179,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
default -> "minecraft:overworld";
|
||||
};
|
||||
// Rounded so an idle roster doesn't look "changed" on every tick.
|
||||
list.add(new LiveMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
|
||||
list.add(new net.scopenet.worldmap.WorldMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
|
||||
Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0,
|
||||
Math.round(at.getYaw()), Math.round(at.getPitch())));
|
||||
}
|
||||
integration.liveMapPlayers(list);
|
||||
integration.mapPlayers(list);
|
||||
}
|
||||
|
||||
public static String dimension(World world) {
|
||||
|
||||
@@ -133,7 +133,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
Map<String, Location> playerHomes = homes.get(player.getUniqueId());
|
||||
if (playerHomes != null && playerHomes.containsKey(homeName)) {
|
||||
player.closeInventory();
|
||||
if (coolingDown(player, "home")) return;
|
||||
player.teleport(playerHomes.get(homeName));
|
||||
startCooldown(player, "home");
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName);
|
||||
}
|
||||
} else if (title.equals(GUI_WARPS_TITLE)) {
|
||||
@@ -143,7 +145,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase();
|
||||
if (warps.containsKey(warpName)) {
|
||||
player.closeInventory();
|
||||
if (coolingDown(player, "warp")) return;
|
||||
player.teleport(warps.get(warpName));
|
||||
startCooldown(player, "warp");
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +162,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
|
||||
if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; }
|
||||
String cmd = command.getName().toLowerCase();
|
||||
boolean cooled = cooldownSeconds(cmd) > 0 && !player.hasPermission("scopenet.cooldown.bypass");
|
||||
if (cooled && coolingDown(player, cmd)) return true;
|
||||
Location before = player.getLocation();
|
||||
switch (cmd) {
|
||||
case "spawn" -> handleSpawn(player);
|
||||
case "home" -> handleHome(player, args);
|
||||
@@ -178,6 +185,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
case "playtime" -> handlePlaytime(player, args);
|
||||
default -> { return false; }
|
||||
}
|
||||
if (cooled && ((cmd.equals("tpa") && args.length > 0) || cmd.equals("rtp") || !before.equals(player.getLocation()))) startCooldown(player, cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -263,13 +271,36 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
player.openInventory(inv);
|
||||
}
|
||||
|
||||
// ---- limits and cooldowns (the same rules as the Fabric mod; see config.yml) ----
|
||||
|
||||
private final net.scopenet.core.Limits.Cooldowns cooldowns = new net.scopenet.core.Limits.Cooldowns();
|
||||
|
||||
private int tpaTimeoutSeconds() { return Math.max(5, plugin.getConfig().getInt("essentials.tpa-timeout-seconds", 60)); }
|
||||
|
||||
private int cooldownSeconds(String cmd) { return Math.max(0, plugin.getConfig().getInt("essentials.cooldowns." + cmd, 0)); }
|
||||
|
||||
/** True (and tells the player) if they must still wait before using this command. */
|
||||
private boolean coolingDown(Player player, String cmd) {
|
||||
if (cooldownSeconds(cmd) <= 0 || player.hasPermission("scopenet.cooldown.bypass")) return false;
|
||||
long wait = cooldowns.remainingMs(player.getUniqueId(), cmd, System.currentTimeMillis());
|
||||
if (wait <= 0) return false;
|
||||
player.sendMessage(ChatColor.RED + "Please wait " + net.scopenet.core.Limits.Cooldowns.waitText(wait) + " before using /" + cmd + " again.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void startCooldown(Player player, String cmd) {
|
||||
if (!player.hasPermission("scopenet.cooldown.bypass")) cooldowns.start(player.getUniqueId(), cmd, System.currentTimeMillis(), cooldownSeconds(cmd));
|
||||
}
|
||||
|
||||
private void handleSetHome(Player player, String[] args) {
|
||||
String homeName = args.length > 0 ? args[0].toLowerCase() : "home";
|
||||
int maxHomes = plugin.getConfig().getInt("essentials.max-homes", 5);
|
||||
int nameMax = Math.max(1, plugin.getConfig().getInt("essentials.home-name-max-length", 32));
|
||||
if (homeName.length() > nameMax) { player.sendMessage(ChatColor.RED + "Home names can be at most " + nameMax + " characters."); return; }
|
||||
int maxHomes = net.scopenet.core.Limits.tier(player::hasPermission, "scopenet.homes.", plugin.getConfig().getInt("essentials.max-homes", 5), 500);
|
||||
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
|
||||
|
||||
if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) {
|
||||
player.sendMessage(ChatColor.RED + "You have reached your limit of " + maxHomes + " homes.");
|
||||
player.sendMessage(ChatColor.RED + "You have reached your limit of " + net.scopenet.core.Limits.show(maxHomes) + " homes.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -324,13 +355,13 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
|
||||
player.sendMessage(ChatColor.GREEN + "Teleport request sent to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + ".");
|
||||
target.sendMessage(ChatColor.YELLOW + player.getName() + ChatColor.GREEN + " has sent you a teleport request.");
|
||||
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in 60s).");
|
||||
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in " + tpaTimeoutSeconds() + "s).");
|
||||
}
|
||||
|
||||
private void handleTpAccept(Player player) {
|
||||
UUID requesterId = pendingTpa.get(player.getUniqueId());
|
||||
Long time = tpaTimestamp.get(player.getUniqueId());
|
||||
if (requesterId == null || time == null || System.currentTimeMillis() - time > 60_000) {
|
||||
if (requesterId == null || time == null || System.currentTimeMillis() - time > tpaTimeoutSeconds() * 1000L) {
|
||||
pendingTpa.remove(player.getUniqueId());
|
||||
tpaTimestamp.remove(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "You have no active teleport requests.");
|
||||
@@ -366,15 +397,18 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
}
|
||||
|
||||
private void handleRtp(Player player) {
|
||||
int radius = plugin.getConfig().getInt("essentials.rtp-radius", 2500);
|
||||
int radius = Math.max(10, plugin.getConfig().getInt("essentials.rtp-radius", 2500));
|
||||
int minRadius = Math.min(plugin.getConfig().getInt("essentials.rtp-min-radius", 0), radius - 1);
|
||||
int attempts = Math.max(1, plugin.getConfig().getInt("essentials.rtp-attempts", 15));
|
||||
World world = player.getWorld();
|
||||
Random rng = new Random();
|
||||
|
||||
player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination...");
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
for (int i = 0; i < 15; i++) {
|
||||
for (int i = 0; i < attempts; i++) {
|
||||
int x = rng.nextInt(radius * 2) - radius;
|
||||
int z = rng.nextInt(radius * 2) - radius;
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < minRadius) continue;
|
||||
int y = world.getHighestBlockYAt(x, z);
|
||||
Block block = world.getBlockAt(x, y - 1, z);
|
||||
if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) {
|
||||
@@ -471,6 +505,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
if (!warpAdmin(player)) return;
|
||||
String name = warpName(player, args, "/setwarp <name>");
|
||||
if (name == null) return;
|
||||
int maxWarps = plugin.getConfig().getInt("essentials.max-warps", 0);
|
||||
if (maxWarps > 0 && !warps.containsKey(name) && warps.size() >= maxWarps) {
|
||||
player.sendMessage(ChatColor.RED + "The server already has " + maxWarps + " warps. Delete one first.");
|
||||
return;
|
||||
}
|
||||
warps.put(name, player.getLocation());
|
||||
saveWarps();
|
||||
player.sendMessage(ChatColor.GREEN + "Warp set: " + name);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -46,15 +46,38 @@ social:
|
||||
essentials:
|
||||
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
|
||||
enabled: true
|
||||
# Maximum homes per player
|
||||
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
|
||||
# scopenet.homes.25 gives 25 (the highest one a player has wins), scopenet.homes.unlimited removes the limit.
|
||||
# Operators and anyone with "*" get the top tier.
|
||||
max-homes: 5
|
||||
# RTP search radius
|
||||
# Longest allowed home name.
|
||||
home-name-max-length: 32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
max-warps: 0
|
||||
# How long a /tpa request stays open.
|
||||
tpa-timeout-seconds: 60
|
||||
# /rtp picks a random spot between rtp-min-radius and rtp-radius blocks from the centre (0,0).
|
||||
rtp-radius: 2500
|
||||
rtp-min-radius: 0
|
||||
# How many random spots /rtp tries before giving up.
|
||||
rtp-attempts: 15
|
||||
# Seconds a player must wait between uses (0 = no cooldown). The wait starts when the command actually
|
||||
# teleports, so a typo costs nothing. Players with scopenet.cooldown.bypass skip all of these.
|
||||
cooldowns:
|
||||
spawn: 0
|
||||
home: 0
|
||||
back: 0
|
||||
tpa: 0
|
||||
rtp: 0
|
||||
warp: 0
|
||||
|
||||
livemap:
|
||||
# Send live player positions to the panel for the launcher BlueMap claim view.
|
||||
# Configure BlueMap's public address and enable live positions in the panel.
|
||||
map:
|
||||
# The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed,
|
||||
# with player positions, guild claims and pins, to the panel. Players see it in the launcher; admins in the panel.
|
||||
# It also needs the map switched on for this server in the panel (it is by default).
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private.
|
||||
show-homes: false
|
||||
|
||||
economy:
|
||||
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
|
||||
@@ -94,15 +117,21 @@ integrations:
|
||||
report-interval-seconds: 30
|
||||
|
||||
# ==============================================================================
|
||||
# BlueMap
|
||||
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
|
||||
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
|
||||
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
|
||||
# Rewards
|
||||
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
|
||||
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
|
||||
# to this server when the player is online. Permissions and groups are applied with console commands, so any
|
||||
# permissions plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
|
||||
# ==============================================================================
|
||||
bluemap:
|
||||
rewards:
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
|
||||
show-homes: false
|
||||
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the panel
|
||||
# could otherwise run commands on this server. Only switch it on if you trust everyone with panel admin access.
|
||||
allow-commands: false
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-deny-command: "lp user {player} permission set {node} false"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
|
||||
# ==============================================================================
|
||||
# Client mod link
|
||||
|
||||
@@ -4,13 +4,16 @@ main: net.scopenet.paper.ScopenetPlugin
|
||||
api-version: '1.20'
|
||||
description: SCOPENET private authentication, access control and activity reporting.
|
||||
load: STARTUP
|
||||
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap]
|
||||
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
|
||||
|
||||
commands:
|
||||
scopenet:
|
||||
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,7 +6,7 @@ pluginManagement {
|
||||
}
|
||||
}
|
||||
rootProject.name = 'scopenet-integrations'
|
||||
include 'common', 'api', 'paper', 'bluemap'
|
||||
include 'common', 'api', 'paper'
|
||||
// Build loaders separately: Forge and Loom use different Gradle generations.
|
||||
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
|
||||
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
|
||||
|
||||
@@ -155,8 +155,7 @@ pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f6
|
||||
if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() {
|
||||
return Err("FOV must be between 30 and 110 degrees".into());
|
||||
}
|
||||
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default()
|
||||
.insert("fov".into(), format!("{fov:.3}"));
|
||||
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default().insert("fov".into(), format!("{fov:.3}"));
|
||||
state.save_settings().map_err(aerr)
|
||||
}
|
||||
|
||||
@@ -635,90 +634,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
if !resp.status().is_success() {
|
||||
return Err("unable to load servers".into());
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
let urls = data["servers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s["id"].as_i64()?, s["map_address"].as_str()?.to_owned())))
|
||||
.collect();
|
||||
*state.map_urls.lock().unwrap() = urls;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MapResource {
|
||||
status: u16,
|
||||
data: Option<String>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
/// Native transport for BlueMap settings and 2D tiles. Only paths under the
|
||||
/// BlueMap address supplied by the panel can be fetched.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||
let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
let root = base.clone();
|
||||
if !matches!(target.scheme(), "http" | "https")
|
||||
|| target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside the configured BlueMap site".into());
|
||||
}
|
||||
fetch_map_asset(target, etag, None).await
|
||||
}
|
||||
|
||||
/// What the launcher needs to offer a Live Map button for one game server.
|
||||
#[tauri::command]
|
||||
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
|
||||
use base64::Engine;
|
||||
if target.as_str().len() > 2048 {
|
||||
return Err("map asset URL is too long".into());
|
||||
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
|
||||
/// because the map loads them straight from the panel as images).
|
||||
#[tauri::command]
|
||||
pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(err)?;
|
||||
let mut request = client.get(target);
|
||||
if let Some(token) = bearer {
|
||||
request = request.bearer_auth(token);
|
||||
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
|
||||
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
|
||||
}
|
||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Players now, plus the claims and pins the game server last sent.
|
||||
#[tauri::command]
|
||||
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
let response = request.send().await.map_err(err)?;
|
||||
let status = response.status().as_u16();
|
||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if status == 304 {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
|
||||
return Err("BlueMap asset exceeds 64 MiB".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(err)?;
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("BlueMap asset exceeds 64 MiB".into());
|
||||
}
|
||||
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -735,6 +681,17 @@ pub async fn get_my_level(state: State<'_, AppState>) -> Res<UserLevelInfo> {
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
/// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:<id>`.
|
||||
#[tauri::command]
|
||||
pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/reward-bundles").await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_my_quests(state: State<'_, AppState>) -> Res<Vec<UserQuest>> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?;
|
||||
@@ -805,14 +762,18 @@ async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String
|
||||
pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?;
|
||||
let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not request to join guild").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not load guild requests").await);
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
@@ -820,44 +781,73 @@ pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: Strin
|
||||
pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?;
|
||||
let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not review guild request").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not load guild roles").await);
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); }
|
||||
let resp =
|
||||
account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not create guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")).await?.json(&serde_json::json!({"role":role})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); }
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role"))
|
||||
.await?
|
||||
.json(&serde_json::json!({"role":role}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not assign guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); }
|
||||
let resp =
|
||||
account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not delete guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_guild(state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")).await?
|
||||
pub async fn update_guild(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
description: String,
|
||||
motd: String,
|
||||
icon_url: String,
|
||||
banner_url: String,
|
||||
) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}"))
|
||||
.await?
|
||||
.json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url}))
|
||||
.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); }
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not update guild").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -991,7 +981,13 @@ pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, serv
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
|
||||
pub async fn transfer_guild_wallet(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
server_id: i64,
|
||||
amount: f64,
|
||||
withdraw: bool,
|
||||
) -> Res<serde_json::Value> {
|
||||
let action = if withdraw { "withdraw" } else { "deposit" };
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
|
||||
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
|
||||
@@ -1245,7 +1241,6 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
|
||||
Ok(body["liked"].as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map actions and guild invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -228,7 +228,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
state.game.lock().unwrap().push(RunningGame { run_id: run_id.clone(), instance_id: instance_id.clone(), kill: Some(kill_tx) });
|
||||
app.emit("launch://state", StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None }).ok();
|
||||
app.emit(
|
||||
"launch://state",
|
||||
StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None },
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match settings.after_launch.as_str() {
|
||||
@@ -337,8 +341,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
}
|
||||
}
|
||||
}
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report })
|
||||
.ok();
|
||||
app2.emit(
|
||||
"game://exit",
|
||||
ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report },
|
||||
)
|
||||
.ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@@ -97,10 +97,11 @@ pub fn run() {
|
||||
commands::get_player_stats,
|
||||
commands::get_leaderboard,
|
||||
commands::get_public_servers,
|
||||
commands::fetch_bluemap_resource,
|
||||
commands::get_livemap_info,
|
||||
commands::get_map_info,
|
||||
commands::get_map_overlay,
|
||||
commands::get_my_level,
|
||||
commands::get_my_quests,
|
||||
commands::get_reward_summaries,
|
||||
commands::claim_quest,
|
||||
commands::get_my_achievements,
|
||||
commands::get_guilds,
|
||||
|
||||
@@ -4,20 +4,19 @@ use crate::settings::Settings;
|
||||
use scopenet_core::Layout;
|
||||
use scopenet_shared::LauncherManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashMap;
|
||||
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");
|
||||
@@ -41,8 +40,6 @@ pub struct AppState {
|
||||
pub accounts: RwLock<AccountsFile>,
|
||||
pub secrets: Secrets,
|
||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||
/// BlueMap addresses received from the panel, keyed by linked game server.
|
||||
pub map_urls: Mutex<HashMap<i64, String>>,
|
||||
/// Install/launch pipeline in progress (abortable).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Vec<RunningGame>>,
|
||||
@@ -61,7 +58,6 @@ impl AppState {
|
||||
settings: RwLock::new(settings),
|
||||
accounts: RwLock::new(accounts),
|
||||
manifest: RwLock::new(manifest),
|
||||
map_urls: Mutex::new(HashMap::new()),
|
||||
task: Mutex::new(None),
|
||||
game: Mutex::new(Vec::new()),
|
||||
data_dir,
|
||||
|
||||
@@ -23,31 +23,19 @@ pub fn capture(state: &AppState) -> Option<Recorder> {
|
||||
let accounts = state.accounts.read().unwrap();
|
||||
let active = accounts.active()?;
|
||||
|
||||
Some(Recorder {
|
||||
http: state.http.clone(),
|
||||
panel,
|
||||
token,
|
||||
username: Some(active.username.clone()),
|
||||
})
|
||||
Some(Recorder { http: state.http.clone(), panel, token, username: Some(active.username.clone()) })
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
let mut req = self
|
||||
.http
|
||||
.post(format!("{}/api/v1/launcher/events", self.panel))
|
||||
.timeout(Duration::from_secs(5));
|
||||
let mut req = self.http.post(format!("{}/api/v1/launcher/events", self.panel)).timeout(Duration::from_secs(5));
|
||||
if let Some(token) = &self.token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
req.json(&LaunchEvent {
|
||||
kind: kind.into(),
|
||||
instance_id: instance.into(),
|
||||
username: self.username.clone(),
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
req.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: self.username.clone() })
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -40,10 +40,18 @@ html, body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */
|
||||
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. */
|
||||
.row > *, .col > *, .glass > * { min-width: 0; }
|
||||
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
|
||||
|
||||
|
||||
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
|
||||
p { margin: 0; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
@@ -84,9 +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;
|
||||
white-space: nowrap;
|
||||
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
|
||||
white-space: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
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; }
|
||||
@@ -154,3 +166,6 @@ label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
|
||||
code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; }
|
||||
pre { white-space: pre-wrap; }
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
|
||||
import { openUrl } from '../lib/tauri';
|
||||
import MapPlayerPanel from './MapPlayerPanel.svelte';
|
||||
|
||||
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
|
||||
|
||||
let frame = $state<HTMLIFrameElement>();
|
||||
let loaded = $state(false);
|
||||
let selected = $state<{ uuid: string; name: string } | null>(null);
|
||||
let nonce = $state(0);
|
||||
|
||||
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
|
||||
|
||||
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
|
||||
const m = e.data;
|
||||
if (!m || m.source !== 'scopenet-map') return;
|
||||
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
|
||||
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
|
||||
else if (m.type === 'deselect') selected = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
});
|
||||
|
||||
const reload = () => { loaded = false; selected = null; nonce++; };
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
|
||||
<header class="glass">
|
||||
<div class="title">
|
||||
<span class="live" class:on={loaded}></span>
|
||||
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
|
||||
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
{#key nonce}
|
||||
<iframe
|
||||
bind:this={frame}
|
||||
title="{serverName} live map"
|
||||
src={address}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
|
||||
allow="fullscreen; clipboard-write"
|
||||
referrerpolicy="no-referrer"
|
||||
onload={() => (loaded = true)}
|
||||
></iframe>
|
||||
{/key}
|
||||
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
|
||||
{#if selected}
|
||||
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
|
||||
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
|
||||
.title { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.title div { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.title .muted { font-size: 0.75rem; }
|
||||
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
|
||||
.grow { flex: 1; }
|
||||
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
|
||||
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
|
||||
</style>
|
||||
@@ -2,15 +2,15 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { toast } from '../lib/store.svelte';
|
||||
import { activeAccount, toast } from '../lib/store.svelte';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
import BlueMapTiles from './BlueMapTiles.svelte';
|
||||
import ClaimTerrain from './ClaimTerrain.svelte';
|
||||
|
||||
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
||||
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
||||
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
||||
} = $props();
|
||||
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
|
||||
type GameServer = { id: number; name: string; instance_id: string; map?: boolean };
|
||||
type Player = { x: number; z: number; dimension: string };
|
||||
let servers = $state<GameServer[]>([]);
|
||||
let serverId = $state<number | null>(null);
|
||||
@@ -32,9 +32,10 @@
|
||||
|
||||
async function refreshPosition(id: number) {
|
||||
try {
|
||||
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
|
||||
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
|
||||
if (serverId !== id) return;
|
||||
player = info.me ?? null;
|
||||
const mine = activeAccount()?.uuid?.toLowerCase();
|
||||
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
|
||||
if (player && centeredServer !== id) {
|
||||
centeredServer = id;
|
||||
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
||||
@@ -134,20 +135,20 @@
|
||||
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedServer?.map_address}
|
||||
{#key `${serverId}:${selectedServer.map_address}`}
|
||||
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||
{#if selectedServer?.map}
|
||||
{#key serverId}
|
||||
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||
{claims} {myGuildId} {player} onpaint={paintChunks}
|
||||
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
|
||||
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
|
||||
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
|
||||
{#if painting}<span>Updating claims…</span>{/if}
|
||||
{#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
|
||||
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,113 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke, inTauri } from '../lib/tauri';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
|
||||
type Resource = { status: number; data: string | null };
|
||||
type Site = { maps?: string[]; mapDataRoot?: string };
|
||||
type MapSettings = { lowres?: { tileSize?: [number, number]; lodFactor?: number; lodCount?: number } };
|
||||
type Point = { x: number; z: number };
|
||||
const cache = new Map<string, string>();
|
||||
const pending = new Set<string>();
|
||||
|
||||
let { address, serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||
serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
|
||||
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
|
||||
oncenterchange: (x: number, z: number) => void;
|
||||
} = $props();
|
||||
|
||||
const TILE = 256;
|
||||
let host: HTMLDivElement;
|
||||
let width = $state(800), height = $state(480);
|
||||
let pixelsPerBlock = $state(3);
|
||||
let worldX = $state(8), worldZ = $state(8);
|
||||
let mapRoot = $state('');
|
||||
let mapId = $state('');
|
||||
let tileBlocks = $state(32);
|
||||
let images = $state<Record<string, string>>({});
|
||||
let info = $state<{ ready: boolean; message: string; tile_base: string; token: string | null; token_ttl_secs: number; dimensions: Array<{ slug: string; bounds: { min_x: number; max_x: number; min_y: number; max_y: number } | null }> } | null>(null);
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let paint = $state<Point[]>([]);
|
||||
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
|
||||
let broken = $state<Record<string, true>>({});
|
||||
|
||||
async function resource(url: string): Promise<Resource> {
|
||||
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return { status: response.status, data: null };
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return { status: response.status, data: btoa(binary) };
|
||||
}
|
||||
|
||||
async function json<T>(url: string): Promise<T> {
|
||||
const response = await resource(url);
|
||||
if (response.status !== 200 || !response.data) throw new Error(`BlueMap returned ${response.status} for settings`);
|
||||
return JSON.parse(atob(response.data)) as T;
|
||||
}
|
||||
|
||||
function tilePath(x: number, z: number): string {
|
||||
const part = (n: number) => (n < 0 ? '-' : '') + Math.abs(n).toString().split('').join('/');
|
||||
return `x${part(x)}/z${part(z)}.png`;
|
||||
}
|
||||
|
||||
function chooseMap(ids: string[]): string {
|
||||
const slug = dimension.replace('minecraft:', '');
|
||||
if (slug === 'overworld') return ids.find((id) => !/nether|the_end|(^|[_-])end($|[_-])/i.test(id)) ?? '';
|
||||
return ids.find((id) => slug === 'the_nether' ? /nether/i.test(id) : /(^|[_-])end($|[_-])/i.test(id)) ?? '';
|
||||
}
|
||||
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
|
||||
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? null);
|
||||
|
||||
// Tile keys last an hour; renew well before they expire.
|
||||
$effect(() => {
|
||||
const url = address, dim = dimension;
|
||||
let alive = true;
|
||||
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
|
||||
(async () => {
|
||||
const base = new URL(url.endsWith('/') ? url : `${url}/`);
|
||||
const site = await json<Site>(new URL('settings.json', base).href);
|
||||
const id = chooseMap(site.maps ?? []);
|
||||
if (!id) throw new Error(`BlueMap has no ${dim.replace('minecraft:', '')} map`);
|
||||
const root = new URL(`${(site.mapDataRoot || 'maps').replace(/\/$/, '')}/${encodeURIComponent(id)}/`, base).href;
|
||||
const settings = await json<MapSettings>(new URL('settings.json', root).href);
|
||||
if (!alive) return;
|
||||
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
|
||||
mapRoot = root; mapId = id; loading = false;
|
||||
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
|
||||
return () => { alive = false; };
|
||||
const id = serverId;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout>;
|
||||
loading = true; error = ''; broken = {};
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
|
||||
if (!alive) return;
|
||||
info = next; loading = false; error = next.ready ? '' : next.message;
|
||||
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
loading = false; error = typeof e === 'string' ? e : (e as Error)?.message ?? 'Could not load the map';
|
||||
timer = setTimeout(load, 15000);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { alive = false; clearTimeout(timer); };
|
||||
});
|
||||
|
||||
const tileUrl = (x: number, y: number) => `${info!.tile_base}/${slug}/0/${x}/${y}.png?t=${info!.token}`;
|
||||
|
||||
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
|
||||
|
||||
const left = $derived(worldX - width / (2 * pixelsPerBlock));
|
||||
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
|
||||
const visibleTiles = $derived.by(() => {
|
||||
if (!mapRoot) return [] as { key: string; url: string; left: number; top: number; size: number }[];
|
||||
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
|
||||
const out = [];
|
||||
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
|
||||
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
|
||||
const b = here.bounds;
|
||||
const minX = Math.floor(left / TILE), maxX = Math.floor((left + width / pixelsPerBlock) / TILE);
|
||||
const minZ = Math.floor(top / TILE), maxZ = Math.floor((top + height / pixelsPerBlock) / TILE);
|
||||
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
|
||||
const url = new URL(`tiles/1/${tilePath(x, z)}`, mapRoot).href;
|
||||
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
|
||||
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
|
||||
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
|
||||
const url = tileUrl(x, z);
|
||||
if (broken[url]) continue;
|
||||
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const tiles = visibleTiles;
|
||||
for (const tile of tiles) {
|
||||
if (images[tile.key] || pending.has(tile.key)) continue;
|
||||
if (cache.has(tile.key)) { images = { ...images, [tile.key]: cache.get(tile.key)! }; continue; }
|
||||
pending.add(tile.key);
|
||||
resource(tile.url).then((r) => {
|
||||
if (r.status !== 200 || !r.data) return;
|
||||
const src = `data:image/png;base64,${r.data}`;
|
||||
cache.set(tile.key, src);
|
||||
if (cache.size > 512) cache.delete(cache.keys().next().value!);
|
||||
images = { ...images, [tile.key]: src };
|
||||
}).catch(() => {}).finally(() => pending.delete(tile.key));
|
||||
}
|
||||
});
|
||||
|
||||
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
|
||||
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
|
||||
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
|
||||
@@ -133,7 +94,7 @@
|
||||
}
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (!mapRoot) return;
|
||||
if (!info?.ready) return;
|
||||
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
|
||||
e.preventDefault();
|
||||
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
|
||||
@@ -170,11 +131,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="BlueMap territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
|
||||
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="Territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
|
||||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
|
||||
{#each visibleTiles as tile (tile.key)}
|
||||
{#if images[tile.key]}<img class="tile" src={images[tile.key]} alt="" draggable="false" style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />{/if}
|
||||
<img class="tile" src={tile.url} alt="" draggable="false" onerror={() => (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />
|
||||
{/each}
|
||||
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
|
||||
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
|
||||
@@ -191,10 +152,10 @@
|
||||
{/each}
|
||||
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
|
||||
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
|
||||
{#if loading}<div class="message">Loading BlueMap tiles…</div>{/if}
|
||||
{#if error}<div class="message error">{error}</div>{/if}
|
||||
{#if loading}<div class="message">Loading the map…</div>{/if}
|
||||
{#if error && !loading}<div class="message">{error}</div>{/if}
|
||||
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
|
||||
<div class="coords">{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}</div>
|
||||
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -208,7 +169,6 @@
|
||||
.preview.remove { background: #fb718590; }
|
||||
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
|
||||
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
|
||||
.error { color: #fda4af; }
|
||||
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
|
||||
.help { left: 10px; }.coords { right: 10px; }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PlayerLink from './PlayerLink.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
|
||||
@@ -213,7 +214,7 @@
|
||||
</span>
|
||||
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
|
||||
<div class="who">
|
||||
<strong>{m?.name ?? 'Former member'}</strong>
|
||||
<strong>{#if m}<PlayerLink uuid={tx.actor_uuid} name={m.name} />{:else}Former member{/if}</strong>
|
||||
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
|
||||
</div>
|
||||
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, RefreshCw } from '@lucide/svelte';
|
||||
import MapViewer from '@scopenet/map/MapViewer.svelte';
|
||||
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { activeAccount, app } from '../lib/store.svelte';
|
||||
import MapPlayerPanel from './MapPlayerPanel.svelte';
|
||||
|
||||
let { serverId, serverName, instanceId, onclose }: { serverId: number; serverName: string; instanceId: string; onclose: () => void } = $props();
|
||||
|
||||
let info = $state<MapInfo | null>(null);
|
||||
let overlay = $state<MapOverlay | null>(null);
|
||||
let error = $state('');
|
||||
let nonce = $state(0);
|
||||
|
||||
const panel = $derived(activeAccount()?.panel_url ?? app.panelUrl);
|
||||
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
|
||||
const avatarUrl = (uuid: string) => (panel ? `${panel}/api/v1/avatar/${uuid}?size=64&v=${app.skinVersion}` : '');
|
||||
|
||||
// The feed reloads the overlay every couple of seconds and renews the tile key before it expires.
|
||||
onMount(() => startFeed({
|
||||
loadInfo: () => invoke<MapInfo>('get_map_info', { serverId }),
|
||||
loadOverlay: () => invoke<MapOverlay>('get_map_overlay', { serverId }),
|
||||
onInfo: (i) => { info = i; error = ''; },
|
||||
onOverlay: (o) => { overlay = o; error = ''; },
|
||||
onError: (m) => { error = m; }
|
||||
}));
|
||||
|
||||
const reload = () => { info = null; overlay = null; nonce++; };
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
|
||||
<header class="glass">
|
||||
<div class="title">
|
||||
<span class="live" class:on={!!info?.ready}></span>
|
||||
<div><strong>{serverName}</strong><span class="muted">Live Map{#if overlay} · {overlay.players.length} online{/if}</span></div>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
{#key nonce}
|
||||
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error} selfUuid={activeAccount()?.uuid ?? null}>
|
||||
{#snippet player(p: LivePlayer, close: () => void)}
|
||||
<MapPlayerPanel {serverId} {instanceId} uuid={p.uuid} nameHint={p.name} onclose={close} />
|
||||
{/snippet}
|
||||
</MapViewer>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
|
||||
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
|
||||
.title { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.title div { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.title .muted { font-size: 0.75rem; }
|
||||
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
|
||||
.grow { flex: 1; }
|
||||
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; --map-accent: var(--accent); }
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { X, Send, UserPlus, Sparkles, MapPin, LoaderCircle, Shield, Clock, Swords, Star } from '@lucide/svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import PlayerLink from './PlayerLink.svelte';
|
||||
import { activeAccount, toast } from '../lib/store.svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { Guild, GuildMember, UserProfileView } from '../lib/types';
|
||||
@@ -84,7 +85,7 @@
|
||||
<div class="head">
|
||||
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
|
||||
<div class="who">
|
||||
<h3>{name}</h3>
|
||||
<h3><PlayerLink {uuid} {name} /></h3>
|
||||
{#if profile.level_info?.title}<span class="title"><Sparkles size={12} /> {profile.level_info.title}</span>{/if}
|
||||
<span class="presence"><i class:on={online}></i>{online ? 'Online now' : 'Offline'}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { app } from '../lib/store.svelte';
|
||||
|
||||
// A player's name that opens their profile. Safe to place inside other clickable rows.
|
||||
let { uuid, name = '', class: cls = '', children }: { uuid?: string | null; name?: string; class?: string; children?: Snippet } = $props();
|
||||
const open = (e: Event) => { e.stopPropagation(); if (uuid) app.viewProfileUuid = uuid; };
|
||||
</script>
|
||||
|
||||
{#if uuid}
|
||||
<button type="button" class="plink {cls}" title="View {name || 'player'}'s profile" onclick={open}
|
||||
onkeydown={(e) => e.stopPropagation()}>{#if children}{@render children()}{:else}{name}{/if}</button>
|
||||
{:else}
|
||||
<span class={cls}>{#if children}{@render children()}{:else}{name}{/if}</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.plink { all: unset; cursor: pointer; font: inherit; color: inherit; border-radius: 4px; transition: color 0.12s ease; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: inherit; vertical-align: bottom; }
|
||||
.plink:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.plink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -86,7 +86,7 @@
|
||||
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.65rem; bottom: 0.65rem; width: 3.5px; border-radius: 0 3px 3px 0; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
|
||||
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
|
||||
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
|
||||
.inst:hover .tip { opacity: 1; }
|
||||
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
|
||||
.big { width: 2.6rem; height: 2.6rem; position: relative; border-radius: var(--radius-sm); transition: transform 0.12s; }
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
|
||||
.name { gap: 0.5rem; }
|
||||
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name strong { min-width: 0; flex: 0 1 auto; font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
|
||||
.state.online { color: var(--success); }
|
||||
|
||||
@@ -133,14 +133,31 @@ const wallet = {
|
||||
};
|
||||
const mockWallet = () => JSON.parse(JSON.stringify(wallet));
|
||||
|
||||
// `?stress` in the dev URL makes every name, title and description much longer, to check that no screen lets text spill out of its box.
|
||||
const STRESS = typeof location !== 'undefined' && new URLSearchParams(location.search).has('stress');
|
||||
const TEXT_KEYS = /(^|_)(name|title|username|description|motd|message|desc|label|bio|tagline|text|rank|guild)(_|$)/i;
|
||||
function stress(value: unknown, key = ''): unknown {
|
||||
if (Array.isArray(value)) return value.map((v) => stress(v, key));
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stress(v, k)]));
|
||||
if (typeof value === 'string' && TEXT_KEYS.test(key) && /^[A-Za-z][A-Za-z '&:,.\-]{2,60}$/.test(value)) return `${value} With An Extraordinarily Long Unbroken Continuation`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||
const result = await mockInvokeInner(cmd, args);
|
||||
return STRESS ? stress(result) : result;
|
||||
}
|
||||
|
||||
async function mockInvokeInner(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||
await sleep(40);
|
||||
switch (cmd) {
|
||||
case 'get_public_servers':
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_livemap_info':
|
||||
return { enabled: true, ready: true, message: '', me: null, players: 0,
|
||||
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_map_info':
|
||||
return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0,
|
||||
dimensions: [] };
|
||||
case 'get_map_overlay':
|
||||
return { players: [], claims: [], pins: [], updated: null };
|
||||
case 'get_player_stats':
|
||||
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
|
||||
case 'get_leaderboard':
|
||||
@@ -153,7 +170,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
||||
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,
|
||||
@@ -218,6 +235,8 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
||||
return 21;
|
||||
case 'get_my_level':
|
||||
return { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] };
|
||||
case 'get_reward_summaries':
|
||||
return { 'quest:q1': ['$250.00', 'Diamond x3'], 'quest:q2': ['+4 claim chunks'] };
|
||||
case 'get_my_quests':
|
||||
return [
|
||||
{ quest: { id: 'q1', title: 'Deepslate Quarry', description: 'Mine 50 deepslate blocks in the caverns.', quest_type: 'daily', category: 'mining', xp_reward: 120, stat_type: 'mine_deepslate', target_count: 50, icon: 'pickaxe', active: true }, current_count: 35, completed: false, claimed: false, expires_at: '2026-10-01T00:00:00Z' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PlayerLink from '../components/PlayerLink.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Shield, Map, Users, MessageSquare, Plus, Crown, UserCheck, Trash2,
|
||||
@@ -83,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string };
|
||||
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string; inviter_uuid?: string };
|
||||
let invites = $state<Invite[]>([]);
|
||||
async function loadInvites() {
|
||||
try { invites = await invoke<Invite[]>('my_guild_invites'); } catch { invites = []; }
|
||||
@@ -259,7 +260,7 @@
|
||||
{#each invites as inv (inv.id)}
|
||||
<div class="invite">
|
||||
<span class="dot"><Shield size={15} /></span>
|
||||
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by {inv.inviter}</span></span>
|
||||
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by <PlayerLink uuid={inv.inviter_uuid} name={inv.inviter} /></span></span>
|
||||
<button class="sm ghost" onclick={() => answerInvite(inv, false)}>Decline</button>
|
||||
<button class="sm primary" onclick={() => answerInvite(inv, true)} disabled={!!myGuild} title={myGuild ? 'Leave your guild first' : ''}>Accept</button>
|
||||
</div>
|
||||
@@ -450,7 +451,7 @@
|
||||
<div class="author-meta">
|
||||
<Avatar uuid={p.author_uuid} name={p.author_name} size={1.8} />
|
||||
<div class="author-info">
|
||||
<span class="author-name">{p.author_name}</span>
|
||||
<span class="author-name"><PlayerLink uuid={p.author_uuid} name={p.author_name} /></span>
|
||||
<span class="post-date tiny muted">{new Date(p.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ServerStatus from '../components/ServerStatus.svelte';
|
||||
import NewsFeed from '../components/NewsFeed.svelte';
|
||||
import Console from '../components/Console.svelte';
|
||||
import BlueMapModal from '../components/BlueMapModal.svelte';
|
||||
import MapModal from '../components/MapModal.svelte';
|
||||
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||
import { invoke, openUrl } from '../lib/tauri';
|
||||
@@ -13,8 +13,8 @@
|
||||
const b = $derived(app.manifest!.branding);
|
||||
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
||||
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
|
||||
// A game server linked to this instance with BlueMap configured.
|
||||
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
|
||||
// A game server linked to this instance that draws the SCOPENET Map.
|
||||
let mapServer = $state<{ id: number; name: string } | null>(null);
|
||||
let mapOpen = $state(false);
|
||||
$effect(() => {
|
||||
const id = inst?.id;
|
||||
@@ -22,8 +22,8 @@
|
||||
mapOpen = false;
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
|
||||
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
|
||||
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers')
|
||||
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; })
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
});
|
||||
@@ -57,7 +57,7 @@
|
||||
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
|
||||
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
|
||||
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
|
||||
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
|
||||
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
|
||||
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -82,7 +82,7 @@
|
||||
</aside>
|
||||
|
||||
{#if app.consoleOpen}<Console />{/if}
|
||||
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
|
||||
{#if mapOpen && mapServer}<MapModal serverId={mapServer.id} serverName={mapServer.name} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
exploration: Compass
|
||||
};
|
||||
|
||||
let extras = $state<Record<string, string[]>>({});
|
||||
|
||||
async function loadData() {
|
||||
invoke<Record<string, string[]>>('get_reward_summaries').then((r) => (extras = r ?? {})).catch(() => {});
|
||||
loading = true;
|
||||
try {
|
||||
const [lvl, quests, achs] = await Promise.all([
|
||||
@@ -227,6 +230,7 @@
|
||||
<p class="ach-desc">{ach.description}</p>
|
||||
<div class="ach-footer">
|
||||
<span class="xp-tag">+{ach.xp_reward} XP</span>
|
||||
{#each extras[`achievement:${ach.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
|
||||
{#if ach.unlocked}
|
||||
<span class="badge unlocked-tag"><Check size={11} /> Unlocked</span>
|
||||
{:else}
|
||||
@@ -293,6 +297,7 @@
|
||||
<strong>{currentCount}</strong> / {targetCount} ({pct}%)
|
||||
</span>
|
||||
<span class="xp-badge">+{q.xp_reward} XP</span>
|
||||
{#each extras[`quest:${q.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-fill" style:width="{pct}%"></div>
|
||||
@@ -626,6 +631,9 @@
|
||||
}
|
||||
.progress-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -754,6 +762,7 @@
|
||||
align-items: center;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.extra-tag { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-size: 0.7rem; font-weight: 600; color: #fbbf24; background: color-mix(in srgb, #fbbf24 14%, transparent); border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent); }
|
||||
.xp-tag {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
|
||||