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 | ||
|
|
7a1f3431ae | ||
|
|
6a2944a88e | ||
|
|
434ed5338a | ||
|
|
c0daa0507a | ||
|
|
228727a395 | ||
|
|
2817ad2ac5 | ||
|
|
ad7285efac | ||
|
|
e160ad8da3 | ||
|
|
878827625a | ||
|
|
eba2ccfdab | ||
|
|
38c9f249e1 | ||
|
|
2bcdf26351 | ||
|
|
84c9d46892 | ||
|
|
f8862c5431 | ||
|
|
97bdcbb7af | ||
|
|
89f588d2f2 | ||
|
|
9dc7f12fb0 | ||
|
|
745c2178ee | ||
|
|
485a81e0a4 | ||
|
|
1e9428945f | ||
|
|
9f3ed3fe24 | ||
|
|
c4a222451c | ||
|
|
badb5fccc0 | ||
|
|
c256c15d0e | ||
|
|
e6c6073eae | ||
|
|
5defda1128 | ||
|
|
78da10364f | ||
|
|
e344e7144e | ||
|
|
1a321336b4 | ||
|
|
3ceed4382f | ||
|
|
e3e25da616 | ||
|
|
f2362def88 | ||
|
|
fdc54577fd | ||
|
|
8acb4a64e6 | ||
|
|
004862ea88 | ||
|
|
7076e847ea | ||
|
|
602e7fd0f0 | ||
|
|
52de8c3031 | ||
|
|
6deff62a3a | ||
|
|
454620f21e | ||
|
|
590178e41a | ||
|
|
bc12a25a01 | ||
|
|
4be366ff16 | ||
|
|
752f0cc43d | ||
|
|
a9ff92e1d1 | ||
|
|
b4cef85b1f | ||
|
|
4b79f69c20 | ||
|
|
3043f3ab42 | ||
|
|
ebd9de1bee | ||
|
|
c7aa563dac | ||
|
|
522e3a5717 | ||
|
|
1954448219 | ||
|
|
a1f19e86c6 | ||
|
|
c7efd759a8 | ||
|
|
a50963f9e0 | ||
|
|
77a15cab8b | ||
|
|
99b45141fc |
No files matched your search
@@ -5,6 +5,7 @@ target
|
||||
.github
|
||||
data
|
||||
docs
|
||||
/release-artifacts
|
||||
launcher/src
|
||||
launcher/src-tauri/icons
|
||||
launcher/src-tauri/gen
|
||||
+8
-2
@@ -1,8 +1,14 @@
|
||||
# Copy to .env and adjust. Only ADMIN_PASSWORD really matters.
|
||||
# Copy to .env and adjust. If ADMIN_PASSWORD is blank, a random first-start
|
||||
# password is printed in `docker compose logs panel`.
|
||||
PANEL_PORT=8080
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-please
|
||||
ADMIN_PASSWORD=
|
||||
JWT_SECRET=
|
||||
CURSEFORGE_API_KEY=
|
||||
MAX_UPLOAD_MB=2048
|
||||
RUST_LOG=info
|
||||
PUBLIC_URL=
|
||||
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
|
||||
SCOPENET_TRUSTED_PROXIES=
|
||||
|
||||
PANEL_MEMORY=256M
|
||||
@@ -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,165 +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
|
||||
|
||||
release:
|
||||
name: GitHub release
|
||||
needs: [version, launcher, panel-image]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -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
|
||||
@@ -10,3 +10,8 @@ build/
|
||||
/panel/server/data
|
||||
launcher/src-tauri/gen/schemas
|
||||
.DS_Store
|
||||
/integrations/.gradle/
|
||||
/integrations/**/.gradle/
|
||||
/integrations/**/build/
|
||||
/integrations/**/run/
|
||||
/integrations/dist/
|
||||
Generated
+298
-5
@@ -279,6 +279,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -298,8 +299,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -478,6 +481,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -698,6 +707,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -904,6 +919,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
@@ -946,6 +967,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -994,6 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
@@ -1131,6 +1164,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
@@ -2108,6 +2157,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2378,6 +2440,36 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
@@ -2428,6 +2520,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -2579,6 +2677,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.20.0"
|
||||
@@ -2659,6 +2767,15 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2678,6 +2795,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.8",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2693,6 +2826,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2700,6 +2843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3035,6 +3179,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3111,6 +3264,27 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -3288,6 +3462,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -3362,6 +3542,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -3381,10 +3567,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -3406,6 +3602,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3415,6 +3621,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
@@ -3523,6 +3738,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3618,6 +3834,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sha1",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3652,6 +3889,7 @@ version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3761,9 +3999,10 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-core"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"fastnbt",
|
||||
"futures",
|
||||
"hex",
|
||||
@@ -3772,6 +4011,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3781,7 +4021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -3807,23 +4047,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-panel"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hex",
|
||||
"image",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"percent-encoding",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.12.28",
|
||||
"rsa",
|
||||
"scopenet-core",
|
||||
"scopenet-shared",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3838,7 +4083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-shared"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"md-5",
|
||||
"serde",
|
||||
@@ -4149,6 +4394,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -4258,6 +4513,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
@@ -5011,6 +5276,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -5268,6 +5545,22 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
|
||||
+6
-2
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/scopeddlol/SCOPENET-MC"
|
||||
|
||||
@@ -17,7 +17,8 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
|
||||
futures = "0.3"
|
||||
sha1 = "0.10"
|
||||
sha1 = { version = "0.10", features = ["oid"] }
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -37,3 +38,6 @@ strip = true
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 1
|
||||
# Dependencies rarely need a debugger; skipping their debug info keeps
|
||||
# target/ several times smaller.
|
||||
debug = false
|
||||
+7
-5
@@ -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
|
||||
|
||||
@@ -18,11 +20,11 @@ ARG TARGETARCH
|
||||
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild
|
||||
RUN case "$TARGETARCH" in \
|
||||
amd64) echo x86_64-unknown-linux-musl ;; \
|
||||
arm64) echo aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported arch $TARGETARCH" >&2; exit 1 ;; \
|
||||
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild ziglang
|
||||
RUN case "${TARGETARCH:-$(uname -m)}" in \
|
||||
amd64|x86_64) echo x86_64-unknown-linux-musl ;; \
|
||||
arm64|aarch64) echo aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported arch ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
|
||||
esac > /rust-target \
|
||||
&& rustup target add "$(cat /rust-target)"
|
||||
WORKDIR /src
|
||||
|
||||
@@ -25,12 +25,12 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
|
||||
|
||||
### Launcher features
|
||||
|
||||
- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager.
|
||||
- **Accounts** — private panel accounts (username + password, authenticated online-mode servers), and local **offline** usernames for local play. Tokens are encrypted with a key stored in Windows Credential Manager.
|
||||
- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players.
|
||||
- **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,10 +52,12 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
|
||||
### 1. Run the panel
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml
|
||||
curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example
|
||||
# edit .env → set ADMIN_PASSWORD
|
||||
docker compose up -d
|
||||
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
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `http://your-server:8080`, sign in as `admin`, then:
|
||||
@@ -64,40 +66,52 @@ Open `http://your-server:8080`, sign in as `admin`, then:
|
||||
2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack.
|
||||
3. **Settings** → how players sign in.
|
||||
|
||||
> Put the panel behind HTTPS (Caddy, Traefik, nginx, Cloudflare Tunnel…) before sharing it. See [docs/admin-guide.md](docs/admin-guide.md).
|
||||
> Put the panel behind HTTPS (Caddy, Traefik, nginx, Cloudflare Tunnel…) before sharing it. See [docs/deployment.md](docs/deployment.md).
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
crates/shared Wire types shared by launcher and panel (manifest, branding, auth)
|
||||
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping
|
||||
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, authlib, ping
|
||||
integrations/ Paper plugin and Fabric/Forge server mods
|
||||
panel/server Admin panel API (Axum + SQLite)
|
||||
panel/web Admin panel UI (Svelte 5)
|
||||
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
|
||||
- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration
|
||||
- [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
|
||||
- [Local 0.4.0 artifacts](release-artifacts/0.4.0/README.md) — Windows installer, server jars and checksums
|
||||
|
||||
## Configuration reference
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
futures.workspace = true
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
zip.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! authlib-injector: a small Java agent that points the game's
|
||||
//! authentication (sessions, skins, profile signatures) at the panel's
|
||||
//! Yggdrasil server instead of Mojang's.
|
||||
//!
|
||||
//! The launcher fetches it from the panel's mirror first and falls back to
|
||||
//! the official download, verifying the SHA-256 either way.
|
||||
|
||||
use crate::http::{self, Download};
|
||||
use crate::paths::Layout;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const OFFICIAL_LATEST: &str = "https://authlib-injector.yushi.moe/artifact/latest.json";
|
||||
|
||||
/// Shape of `latest.json` (the panel mirror uses the same format).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub build_number: u64,
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksums: Checksums,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Checksums {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub fn sha256_file(path: &Path) -> Result<String> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
Ok(hex::encode(Sha256::digest(&bytes)))
|
||||
}
|
||||
|
||||
fn cache_dir(layout: &Layout) -> PathBuf {
|
||||
layout.cache().join("authlib-injector")
|
||||
}
|
||||
|
||||
/// Newest jar already on disk (used when offline).
|
||||
fn newest_cached(layout: &Layout) -> Option<PathBuf> {
|
||||
std::fs::read_dir(cache_dir(layout))
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "jar"))
|
||||
.max_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
|
||||
}
|
||||
|
||||
async fn from_source(client: &reqwest::Client, layout: &Layout, index_url: &str, base: Option<&str>) -> Result<PathBuf> {
|
||||
let artifact: Artifact = http::get_json(client, index_url).await?;
|
||||
let url = match base {
|
||||
Some(b) => crate::sync::resolve_url(b, &artifact.download_url),
|
||||
None => artifact.download_url.clone(),
|
||||
};
|
||||
let dest = cache_dir(layout).join(format!("authlib-injector-{}.jar", artifact.version));
|
||||
let expected = artifact.checksums.sha256.to_ascii_lowercase();
|
||||
if dest.exists() && sha256_file(&dest)? == expected {
|
||||
return Ok(dest);
|
||||
}
|
||||
http::download_one(client, &Download::new(url, dest.clone(), None, None), &|_| {}).await?;
|
||||
let got = sha256_file(&dest)?;
|
||||
if got != expected {
|
||||
std::fs::remove_file(&dest).ok();
|
||||
bail!("authlib-injector checksum mismatch (expected {expected}, got {got})");
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Make sure authlib-injector is available locally and return its path.
|
||||
pub async fn ensure(client: &reqwest::Client, layout: &Layout, panel_base: Option<&str>) -> Result<PathBuf> {
|
||||
let mut errors = Vec::new();
|
||||
if let Some(base) = panel_base.filter(|b| !b.is_empty()) {
|
||||
let index = format!("{}/api/v1/launcher/authlib-injector.json", base.trim_end_matches('/'));
|
||||
match from_source(client, layout, &index, Some(base)).await {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) => errors.push(format!("panel mirror: {e:#}")),
|
||||
}
|
||||
}
|
||||
match from_source(client, layout, OFFICIAL_LATEST, None).await {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) => errors.push(format!("official download: {e:#}")),
|
||||
}
|
||||
if let Some(cached) = newest_cached(layout) {
|
||||
tracing::warn!("using cached authlib-injector ({})", errors.join("; "));
|
||||
return Ok(cached);
|
||||
}
|
||||
Err(anyhow!("couldn't download authlib-injector: {}", errors.join("; ")))
|
||||
}
|
||||
|
||||
/// Fetch the Yggdrasil metadata so it can be handed to the agent up front
|
||||
/// (saves the game a network round-trip at startup).
|
||||
pub async fn prefetch_metadata(client: &reqwest::Client, api_url: &str) -> Result<String> {
|
||||
let resp = client.get(api_url).send().await?.error_for_status().context("fetching auth server metadata")?;
|
||||
let bytes = resp.bytes().await?;
|
||||
// Must be valid JSON, or the agent would refuse to start.
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes).context("auth server metadata isn't JSON")?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// JVM arguments that load the agent. They must come before the main class.
|
||||
pub fn jvm_args(jar: &Path, api_url: &str, prefetched: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![format!("-javaagent:{}={}", jar.display(), api_url)];
|
||||
if let Some(p) = prefetched {
|
||||
args.push(format!("-Dauthlibinjector.yggdrasil.prefetched={p}"));
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_agent_args() {
|
||||
let args = jvm_args(Path::new("/c/ai.jar"), "https://panel.example/api/yggdrasil", Some("e30="));
|
||||
assert_eq!(args[0], "-javaagent:/c/ai.jar=https://panel.example/api/yggdrasil");
|
||||
assert_eq!(args[1], "-Dauthlibinjector.yggdrasil.prefetched=e30=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_latest_json() {
|
||||
let a: Artifact = serde_json::from_str(
|
||||
r#"{"build_number":53,"version":"1.2.5","release_time":"x","download_url":"https://x/a.jar","checksums":{"sha256":"ab"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.version, "1.2.5");
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,15 @@ pub struct Auth {
|
||||
/// Dashed or undashed UUID.
|
||||
pub uuid: String,
|
||||
pub access_token: String,
|
||||
/// "msa" for Microsoft accounts, "legacy" for offline.
|
||||
/// "mojang" for panel (Yggdrasil) accounts, "legacy" for offline.
|
||||
pub user_type: String,
|
||||
pub xuid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
pub auth: Auth,
|
||||
/// JVM arguments that must come first (the authlib-injector agent).
|
||||
pub agent_args: Vec<String>,
|
||||
pub game_dir: PathBuf,
|
||||
pub memory_min_mb: u32,
|
||||
pub memory_max_mb: u32,
|
||||
@@ -187,7 +188,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
|
||||
vars.insert("auth_access_token", opts.auth.access_token.clone());
|
||||
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
|
||||
vars.insert("clientid", String::new());
|
||||
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
|
||||
vars.insert("auth_xuid", String::new());
|
||||
vars.insert("user_type", opts.auth.user_type.clone());
|
||||
vars.insert("user_properties", "{}".into());
|
||||
vars.insert("version_type", opts.version_label.clone());
|
||||
@@ -203,7 +204,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
|
||||
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
|
||||
}
|
||||
|
||||
let mut args = Vec::new();
|
||||
let mut args = opts.agent_args.clone();
|
||||
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
|
||||
args.push(format!("-Xmx{}M", opts.memory_max_mb));
|
||||
args.extend(gc_args(opts.gc, installed.java_major));
|
||||
@@ -336,8 +337,8 @@ mod tests {
|
||||
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
|
||||
game_dir: "/g".into(),
|
||||
memory_min_mb: 1024,
|
||||
memory_max_mb: 4096,
|
||||
@@ -362,7 +363,7 @@ mod tests {
|
||||
let layout = Layout::new("/root");
|
||||
let cmd = build(&installed(json), &layout, &opts(true));
|
||||
let a = cmd.args.join(" ");
|
||||
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
|
||||
assert!(a.starts_with("-javaagent:/a.jar=https://p/api/yggdrasil -Xms1024M -Xmx4096M"), "agent must come first");
|
||||
assert!(a.contains("-Dcustom=1"));
|
||||
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
|
||||
assert!(a.contains("--width 1280 --height 720"));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! tested without a window.
|
||||
|
||||
pub mod assets;
|
||||
pub mod authlib;
|
||||
pub mod http;
|
||||
pub mod install;
|
||||
pub mod java;
|
||||
@@ -13,7 +14,6 @@ pub mod libraries;
|
||||
pub mod loaders;
|
||||
pub mod maven;
|
||||
pub mod meta;
|
||||
pub mod msa;
|
||||
pub mod options;
|
||||
pub mod paths;
|
||||
pub mod ping;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
|
||||
//!
|
||||
//! Requires an Azure app registration ("client id") that Mojang has approved
|
||||
//! for the Minecraft API. The admin configures it in the panel.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
|
||||
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
|
||||
const SCOPE: &str = "XboxLive.signin offline_access";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceCode {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: u64,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MsaSession {
|
||||
pub refresh_token: String,
|
||||
pub mc_access_token: String,
|
||||
/// Unix seconds.
|
||||
pub mc_expires_at: i64,
|
||||
pub profile: McProfile,
|
||||
pub xuid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub skins: Vec<McSkin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McSkin {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub variant: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
|
||||
let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("Microsoft rejected the sign-in request: {body}");
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Poll until the user finishes signing in, then complete the full chain.
|
||||
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
|
||||
let mut interval = code.interval.max(1);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
bail!("the sign-in code expired, please try again");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||
let resp: TokenResponse = client
|
||||
.post(TOKEN_URL)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("client_id", client_id),
|
||||
("device_code", code.device_code.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
match resp.error.as_deref() {
|
||||
None => {
|
||||
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
|
||||
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
|
||||
return complete(client, &access, refresh).await;
|
||||
}
|
||||
Some("authorization_pending") => continue,
|
||||
Some("slow_down") => interval += 5,
|
||||
Some("authorization_declined") => bail!("sign-in was cancelled"),
|
||||
Some("expired_token") => bail!("the sign-in code expired, please try again"),
|
||||
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh a saved session (used before each launch when the Minecraft
|
||||
/// token is close to expiring).
|
||||
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
|
||||
let resp: TokenResponse = client
|
||||
.post(TOKEN_URL)
|
||||
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
if let Some(err) = resp.error {
|
||||
bail!("your Microsoft session expired ({err}); please sign in again");
|
||||
}
|
||||
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
|
||||
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
|
||||
complete(client, &access, refresh).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct XboxResponse {
|
||||
token: String,
|
||||
display_claims: DisplayClaims,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct DisplayClaims {
|
||||
xui: Vec<Xui>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Xui {
|
||||
uhs: String,
|
||||
#[serde(default)]
|
||||
xid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct McLogin {
|
||||
access_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
|
||||
// Xbox Live
|
||||
let xbl: XboxResponse = client
|
||||
.post("https://user.auth.xboxlive.com/user/authenticate")
|
||||
.json(&json!({
|
||||
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("Xbox Live authentication failed")?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
// XSTS
|
||||
let resp = client
|
||||
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
|
||||
.json(&json!({
|
||||
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status().as_u16() == 401 {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
|
||||
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
|
||||
Some(2148916235) => "Xbox Live isn't available in your country",
|
||||
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
|
||||
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
|
||||
_ => "Xbox Live refused the sign-in",
|
||||
};
|
||||
bail!("{msg}");
|
||||
}
|
||||
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
|
||||
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
|
||||
let uhs = claims.uhs.clone();
|
||||
let xuid = claims.xid.clone();
|
||||
|
||||
// Minecraft
|
||||
let mc: McLogin = client
|
||||
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
|
||||
if resp.status().as_u16() == 404 {
|
||||
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
|
||||
}
|
||||
let profile: McProfile = resp.error_for_status()?.json().await?;
|
||||
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
|
||||
}
|
||||
@@ -4,6 +4,88 @@ use anyhow::Result;
|
||||
use std::collections::BTreeMap;
|
||||
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",
|
||||
];
|
||||
|
||||
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
|
||||
let path = game_dir.join("options.txt");
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
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())
|
||||
}
|
||||
|
||||
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
|
||||
/// else the game has written. Only for 1.13+ (named key codes).
|
||||
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
|
||||
@@ -51,4 +133,20 @@ mod tests {
|
||||
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
|
||||
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
|
||||
}
|
||||
|
||||
#[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();
|
||||
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"));
|
||||
assert_eq!(prefs.get("soundCategory_music").map(String::as_str), Some("0.25"));
|
||||
assert!(!prefs.contains_key("modded"));
|
||||
assert!(!prefs.contains_key("key_key.forward"));
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
|
||||
uuid: scopenet_shared::offline_uuid("CiBot"),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
agent_args: vec![],
|
||||
game_dir,
|
||||
memory_min_mb: 512,
|
||||
memory_max_mb: 2048,
|
||||
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
|
||||
async fn neoforge_1_21_1() {
|
||||
run("1.21.1", Loader::NeoForge).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn authlib_injector_official_download() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let layout = Layout::new(dir.path());
|
||||
let jar = scopenet_core::authlib::ensure(&scopenet_core::http::client(), &layout, None).await.unwrap();
|
||||
let zip = zip::ZipArchive::new(std::fs::File::open(&jar).unwrap()).unwrap();
|
||||
assert!(zip.file_names().any(|n| n == "META-INF/MANIFEST.MF"), "not a jar: {}", jar.display());
|
||||
println!("authlib-injector: {}", jar.display());
|
||||
}
|
||||
+446
-10
@@ -31,6 +31,7 @@ pub struct Branding {
|
||||
pub version_label: String,
|
||||
pub news: Vec<NewsItem>,
|
||||
pub links: Vec<SocialLink>,
|
||||
pub about: AboutContent,
|
||||
pub features: Features,
|
||||
/// Raw CSS appended to the launcher stylesheet (power users).
|
||||
pub custom_css: String,
|
||||
@@ -60,12 +61,22 @@ impl Default for Branding {
|
||||
tag: Some("News".into()),
|
||||
}],
|
||||
links: vec![],
|
||||
about: AboutContent::default(),
|
||||
features: Features::default(),
|
||||
custom_css: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AboutContent {
|
||||
pub title: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub body: String,
|
||||
pub links: Vec<SocialLink>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Background {
|
||||
@@ -175,23 +186,72 @@ pub enum RegistrationMode {
|
||||
pub struct AuthConfig {
|
||||
pub panel_accounts: bool,
|
||||
pub registration: RegistrationMode,
|
||||
pub microsoft: bool,
|
||||
pub microsoft_client_id: Option<String>,
|
||||
pub offline_local: bool,
|
||||
/// Absolute URL of the panel's Yggdrasil (authlib-injector) API root.
|
||||
/// Filled in by the panel; the launcher falls back to `{panel}/api/yggdrasil`.
|
||||
pub yggdrasil_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_accounts: true,
|
||||
registration: RegistrationMode::Closed,
|
||||
microsoft: false,
|
||||
microsoft_client_id: None,
|
||||
offline_local: true,
|
||||
}
|
||||
Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Yggdrasil session tokens handed to the launcher at sign-in. The access
|
||||
/// token is what the game (via authlib-injector) uses to join servers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct YggdrasilTokens {
|
||||
pub access_token: String,
|
||||
pub client_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CapeInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A player's in-game identity: UUID, skin and cape.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct PlayerProfile {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub skin_url: Option<String>,
|
||||
/// "classic" (Steve arms) or "slim" (Alex arms).
|
||||
pub skin_model: String,
|
||||
pub cape: Option<CapeInfo>,
|
||||
/// Capes this player is allowed to pick.
|
||||
pub available_capes: Vec<CapeInfo>,
|
||||
}
|
||||
|
||||
/// A saved skin and cape preset (wardrobe look).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SkinProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub skin_data: Option<String>,
|
||||
#[serde(default = "default_skin_model")]
|
||||
pub skin_model: String,
|
||||
#[serde(default)]
|
||||
pub cape_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub cape_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cape_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
fn default_skin_model() -> String {
|
||||
"classic".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
@@ -210,7 +270,8 @@ pub struct RegisterRequest {
|
||||
pub struct PublicUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
/// Offline-mode UUID (dashed), identical to what an offline server computes.
|
||||
/// The player's UUID (dashed). New accounts get the offline-mode UUID for
|
||||
/// their name, so offline and authenticated servers agree.
|
||||
pub uuid: String,
|
||||
pub role: String,
|
||||
pub groups: Vec<String>,
|
||||
@@ -218,11 +279,15 @@ pub struct PublicUser {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
/// Panel session token (manifest, skins, account API).
|
||||
pub token: String,
|
||||
pub user: PublicUser,
|
||||
/// Set when the account exists but is waiting for admin approval.
|
||||
#[serde(default)]
|
||||
pub pending: bool,
|
||||
/// Game session for authlib-injector; absent for pending accounts.
|
||||
#[serde(default)]
|
||||
pub yggdrasil: Option<YggdrasilTokens>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -302,6 +367,7 @@ pub struct InstanceSummary {
|
||||
pub description: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub mc_version: String,
|
||||
pub loader: Loader,
|
||||
pub loader_version: Option<String>,
|
||||
@@ -354,6 +420,376 @@ pub struct LaunchEvent {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leveling & Quests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct UserLevelInfo {
|
||||
pub uuid: String,
|
||||
pub global_xp: i64,
|
||||
#[serde(default)]
|
||||
pub current_xp: i64,
|
||||
pub global_level: i64,
|
||||
#[serde(default)]
|
||||
pub level: i64,
|
||||
pub current_level_xp: i64,
|
||||
pub next_level_xp: i64,
|
||||
pub progress_pct: f64,
|
||||
pub title: Option<String>,
|
||||
pub badges: Vec<String>,
|
||||
pub server_levels: Vec<ServerLevelInfo>,
|
||||
#[serde(default)]
|
||||
pub rank: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub rewards: Vec<LevelReward>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct LevelReward {
|
||||
pub id: i64,
|
||||
pub level: i64,
|
||||
pub reward_type: String,
|
||||
pub reward_value: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ServerLevelInfo {
|
||||
pub server_id: i64,
|
||||
pub server_name: String,
|
||||
pub server_xp: i64,
|
||||
pub server_level: i64,
|
||||
pub current_level_xp: i64,
|
||||
pub next_level_xp: i64,
|
||||
pub progress_pct: f64,
|
||||
pub rank_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Quest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub period: String, // "daily", "weekly"
|
||||
pub category: String, // "mining", "combat", "building", "farming", "social", "exploration"
|
||||
pub target_stat: String,
|
||||
pub target_count: i64,
|
||||
pub xp_reward: i64,
|
||||
pub icon: String,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub quest_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stat_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct UserQuest {
|
||||
pub quest: Quest,
|
||||
#[serde(default)]
|
||||
pub progress: i64,
|
||||
#[serde(default)]
|
||||
pub current_count: i64,
|
||||
pub completed: bool,
|
||||
pub claimed: bool,
|
||||
pub period_key: String,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Achievement {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub icon_frame: String, // "task", "goal", "challenge"
|
||||
#[serde(default)]
|
||||
pub frame_type: Option<String>,
|
||||
pub icon_item: String,
|
||||
pub icon_bg: String,
|
||||
pub icon_border: String,
|
||||
pub xp_reward: i64,
|
||||
pub secret: bool,
|
||||
#[serde(default)]
|
||||
pub stat_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub target_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub unlocked: bool,
|
||||
#[serde(default)]
|
||||
pub unlocked_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Economy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ServerEconomyBalance {
|
||||
pub server_id: i64,
|
||||
pub server_name: String,
|
||||
pub balance: f64,
|
||||
pub currency_symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct EconomyTransaction {
|
||||
pub id: i64,
|
||||
pub server_id: i64,
|
||||
#[serde(default)]
|
||||
pub server_name: Option<String>,
|
||||
pub from_uuid: String,
|
||||
pub from_name: String,
|
||||
pub to_uuid: String,
|
||||
pub to_name: String,
|
||||
pub amount: f64,
|
||||
pub description: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct BaltopEntry {
|
||||
pub rank: usize,
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub balance: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MarketListing {
|
||||
pub id: i64,
|
||||
pub server_id: i64,
|
||||
pub seller_uuid: String,
|
||||
pub seller_name: String,
|
||||
pub item_id: String,
|
||||
pub item_name: String,
|
||||
pub amount: i32,
|
||||
pub price: f64,
|
||||
pub created_at: String,
|
||||
/// Set when a guild is selling; the sale is paid into its bank.
|
||||
#[serde(default)]
|
||||
pub seller_guild_tag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MemberProfile {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub skin_url: Option<String>,
|
||||
pub global_level: i64,
|
||||
pub title: Option<String>,
|
||||
pub playtime_secs: i64,
|
||||
pub last_seen: Option<String>,
|
||||
pub online: bool,
|
||||
pub is_friend: bool,
|
||||
/// `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
|
||||
#[serde(default)]
|
||||
pub friendship_status: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guilds & Land Claiming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Guild {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
pub description: String,
|
||||
pub motd: String,
|
||||
pub leader_uuid: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub level: i64,
|
||||
pub xp: i64,
|
||||
pub max_claims: i64,
|
||||
pub member_count: i64,
|
||||
pub claims_count: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct GuildMember {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub role: String, // "leader", "officer", "member", "recruit"
|
||||
pub joined_at: String,
|
||||
#[serde(default)]
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct GuildPost {
|
||||
pub id: i64,
|
||||
pub guild_id: String,
|
||||
pub author_uuid: String,
|
||||
pub author_name: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct GuildClaim {
|
||||
pub id: i64,
|
||||
pub guild_id: String,
|
||||
pub guild_name: String,
|
||||
pub guild_tag: String,
|
||||
pub server_id: i64,
|
||||
pub dimension: String,
|
||||
pub chunk_x: i32,
|
||||
pub chunk_z: i32,
|
||||
pub claimed_by_uuid: String,
|
||||
pub claimed_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social: Friends, DMs, Invites, Profiles & Posts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct FriendInfo {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub status: String, // "accepted", "pending_incoming", "pending_outgoing"
|
||||
pub online: bool,
|
||||
pub playing_on: Option<String>,
|
||||
pub last_seen: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct DirectMessage {
|
||||
pub id: i64,
|
||||
pub sender_uuid: String,
|
||||
pub sender_name: String,
|
||||
pub recipient_uuid: String,
|
||||
pub content: String,
|
||||
pub is_read: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct GameInvite {
|
||||
pub id: String,
|
||||
pub sender_uuid: String,
|
||||
pub sender_name: String,
|
||||
pub recipient_uuid: String,
|
||||
pub instance_id: String,
|
||||
pub instance_name: String,
|
||||
pub server_id: Option<i64>,
|
||||
pub server_name: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct UserPost {
|
||||
pub id: i64,
|
||||
pub user_uuid: String,
|
||||
pub author_name: String,
|
||||
pub content: String,
|
||||
pub image_url: Option<String>,
|
||||
pub likes_count: i64,
|
||||
#[serde(default)]
|
||||
pub liked_by_me: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ProfileGuild {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// A rank reported by a server's permissions plugin (LuckPerms), shown read-only.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ProfileRank {
|
||||
pub display: String,
|
||||
pub prefix: String,
|
||||
pub server_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ProfileActivity {
|
||||
/// `join`, `death`, `advancement`, `kill` or `achievement`.
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MutualFriend {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct UserProfileView {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub bio: String,
|
||||
pub banner_url: Option<String>,
|
||||
pub custom_badge: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub global_level: i64,
|
||||
pub global_xp: i64,
|
||||
pub server_levels: Vec<ServerLevelInfo>,
|
||||
pub featured_achievement: Option<Achievement>,
|
||||
pub achievements_count: usize,
|
||||
pub posts: Vec<UserPost>,
|
||||
pub level_info: UserLevelInfo,
|
||||
pub achievements: Vec<Achievement>,
|
||||
pub badges: Vec<String>,
|
||||
pub friends_count: i64,
|
||||
pub created_at: String,
|
||||
pub stats: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub online: bool,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub guild: Option<ProfileGuild>,
|
||||
#[serde(default)]
|
||||
pub rank: Option<ProfileRank>,
|
||||
/// The server they spend the most time on.
|
||||
#[serde(default)]
|
||||
pub favorite_server: Option<String>,
|
||||
/// Total balance across the economies they hold money in.
|
||||
#[serde(default)]
|
||||
pub wealth: f64,
|
||||
#[serde(default)]
|
||||
pub recent_activity: Vec<ProfileActivity>,
|
||||
#[serde(default)]
|
||||
pub mutual_friends: Vec<MutualFriend>,
|
||||
/// Relationship to the person looking: `self`, `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
|
||||
#[serde(default)]
|
||||
pub relationship: String,
|
||||
/// Profile cosmetic: a `#rrggbb` accent colour.
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discord_linked: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+9
-6
@@ -1,14 +1,15 @@
|
||||
# SCOPENET admin panel
|
||||
#
|
||||
# cp .env.example .env # set ADMIN_PASSWORD at least
|
||||
# docker compose up -d
|
||||
# cp .env.example .env # set ADMIN_PASSWORD and PUBLIC_URL
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Then open http://localhost:8080 (or your domain behind a reverse proxy).
|
||||
services:
|
||||
panel:
|
||||
image: ghcr.io/scopeddlol/scopenet-mc-panel:latest
|
||||
# Or build from source: docker compose build
|
||||
build: .
|
||||
# Tag the image built from this checkout so an older GHCR release is not used.
|
||||
image: scopenet-panel:local
|
||||
build:
|
||||
context: .
|
||||
container_name: scopenet-panel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -23,6 +24,8 @@ services:
|
||||
# Optional — can also be set in the panel's Settings page.
|
||||
CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
volumes:
|
||||
- panel-data:/data
|
||||
@@ -30,7 +33,7 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
memory: ${PANEL_MEMORY:-256M}
|
||||
|
||||
volumes:
|
||||
panel-data:
|
||||
+27
-6
@@ -4,7 +4,7 @@
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set ADMIN_PASSWORD
|
||||
docker compose up -d
|
||||
docker compose up -d --build
|
||||
docker compose logs -f panel # first start prints the admin account
|
||||
```
|
||||
|
||||
@@ -19,7 +19,7 @@ Everything the panel stores lives in the `panel-data` volume (`/data`):
|
||||
|
||||
**Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume.
|
||||
|
||||
**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically.
|
||||
**Updating this checkout:** `docker compose up -d --build`. Database migrations run automatically.
|
||||
|
||||
### HTTPS
|
||||
|
||||
@@ -52,20 +52,27 @@ On the **Server** tab: name, address, port.
|
||||
- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added).
|
||||
- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions).
|
||||
|
||||
Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin.
|
||||
Official servers use `online-mode=true`, authlib-injector and the SCOPENET server integration. See [server setup](server-integration.md) for the startup flag, tokens and supported versions.
|
||||
|
||||
### Access
|
||||
|
||||
- **Everyone** — any launcher, including offline and Microsoft accounts.
|
||||
- **Everyone** — any launcher, including local offline accounts.
|
||||
- **Signed-in players** — any panel account.
|
||||
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
|
||||
|
||||
## Players
|
||||
|
||||
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*.
|
||||
- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent.
|
||||
- Each account has a permanent UUID. New accounts receive a random UUID; existing accounts keep theirs. Username changes preserve inventory and reserve prior names. Players change username, skin and permitted capes from the launcher.
|
||||
- Disabling an account signs it out everywhere on the next request.
|
||||
- Ten failed logins lock an account for five minutes.
|
||||
- **Username blacklist:** Settings → Username blacklist. Each entry matches a complete name; wrap it as `*word*` to block it inside longer names. The short starter list covers obvious offensive terms and can be edited. New registrations, admin-created accounts, Discord-created accounts, and username changes all use it.
|
||||
|
||||
### Discord and password reset email
|
||||
|
||||
Set **Settings → Auth server → Public address** to the HTTPS URL players use. In **Settings → Discord**, save the application client ID and client secret, and add `https://your-panel.example/api/v1/auth/discord/callback` to the Discord application's OAuth2 redirect URLs. Players can sign in with Discord in the launcher or connect an existing account under **Accounts → Connections → Discord**. With registration closed, Discord sign-in works for linked accounts only; open or approval mode can create new player accounts.
|
||||
|
||||
In **Settings → Resend SMTP**, save a Resend API key and a sender email from a verified domain. The panel sends password reset links through `smtp.resend.com` over TLS. A reset link expires after 30 minutes and works once. Use **Send a test email** and check the destination inbox and Resend activity log: SMTP acceptance confirms submission, while the inbox confirms delivery. Credentials are stored in the panel data volume and are not returned to the browser after saving; protect the volume and restrict access to the Admin Panel.
|
||||
|
||||
## Launcher design
|
||||
|
||||
@@ -73,6 +80,20 @@ 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`.
|
||||
|
||||
## Progression (quests, XP, levels)
|
||||
|
||||
**Progression** in the sidebar controls how players advance:
|
||||
|
||||
* **Quest limits** – how many daily and weekly quests each player is given (0 = every
|
||||
enabled quest), and whether everyone draws their own set or shares one. Pin quests
|
||||
on the Quests page to always include them. A player's set is stored when they first
|
||||
open their quests for the period, so edits never swap out quests they've started.
|
||||
* **XP & levels** – the level curve (`round(base × L^exponent)`, optional max level),
|
||||
XP per playtime hour / kill / block / message, and panel-wide multipliers that stack
|
||||
with each server's own. Saving a new curve recalculates stored levels; nobody loses XP.
|
||||
* **Players** – search a player, then give, take, set or reset XP (global or per
|
||||
server) or set their level. Changes are recorded in the Activity log with your reason.
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## Launch pipeline
|
||||
|
||||
1. Resolve the account (refreshing Microsoft tokens if needed).
|
||||
1. Resolve the account and validate its private Yggdrasil session.
|
||||
2. Fetch the instance from the panel (cached for offline play).
|
||||
3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives.
|
||||
4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Docker Compose deployment
|
||||
|
||||
This deployment builds the panel and web UI from the checked-out source. It does
|
||||
not need GitHub Actions or a published GHCR image.
|
||||
|
||||
1. Install Docker Engine with the Compose plugin on a Linux host. Point a DNS
|
||||
name at that host and arrange HTTPS through your reverse proxy.
|
||||
2. Check out the commit you want to deploy, then copy `.env.example` to `.env`.
|
||||
Set `ADMIN_PASSWORD` to a unique password and `PUBLIC_URL` to the public
|
||||
`https://` URL. Set `SCOPENET_TRUSTED_PROXIES` only to the IP addresses of
|
||||
proxies that send forwarding headers.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
docker compose config --quiet
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 panel
|
||||
```
|
||||
|
||||
The panel listens on `PANEL_PORT` (default `8080`). Docker checks
|
||||
`/scopenet-panel healthcheck` inside the container. Wait for `healthy` in
|
||||
`docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
|
||||
blank on first start, the generated password appears in the panel logs.
|
||||
|
||||
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
|
||||
upgrading. To back it up, stop the panel, archive the volume with a temporary
|
||||
container, then start it again:
|
||||
|
||||
```bash
|
||||
docker compose stop panel
|
||||
docker run --rm -v scopenet-mc_panel-data:/data:ro -v "$PWD":/backup alpine tar czf /backup/panel-data.tar.gz -C /data .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If your Compose project has a different name, use the volume name shown by
|
||||
`docker volume ls`. To upgrade, pull the desired commit and run
|
||||
`docker compose up -d --build` again. Do not use `docker compose down -v` unless
|
||||
you intend to delete the panel data.
|
||||
@@ -34,3 +34,7 @@ cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings
|
||||
## Releasing
|
||||
|
||||
Push a tag `vX.Y.Z` or run the **Release** workflow. It stamps the version into `Cargo.toml` and `tauri.conf.json`, builds the NSIS installer on Windows, pushes the multi-arch panel image to GHCR, and publishes the release.
|
||||
|
||||
## Server integrations
|
||||
|
||||
See [server integration builds and validation](server-integration.md#build-and-verify). The Java modules live under `integrations/` and the release workflow publishes the per-loader/per-version JARs.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Fabric: server mod features and the client mod
|
||||
|
||||
Two jars, both for **Fabric 1.20.1**:
|
||||
|
||||
| Jar | Install on | Needs |
|
||||
|---|---|---|
|
||||
| `scopenet-fabric-1.20.1.jar` (server mod) | the server (and a single-player world if you want the commands there) | Fabric API. Optional: LuckPerms, Placeholder API |
|
||||
| `scopenet-client-fabric-1.20.1.jar` (client mod) | players who want the HUD, claim borders and shop/market windows | Fabric API |
|
||||
|
||||
The client mod is optional and display-only. The server stays in charge of claims, money and permissions; every button in the client is an ordinary command. A player without it loses nothing. You can install both jars on the same machine (for example a single-player world), because the client mod shares no code with the server mod.
|
||||
|
||||
Other Minecraft versions keep the original behaviour (sign-in, stats, claims, Live Map) and do not get the commands yet.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
gradle -p integrations -Ploader=fabric -PmcVersion=1.20.1 :fabric:build # server mod
|
||||
gradle -p integrations -Ploader=fabric-client :fabric-client:build # client mod
|
||||
```
|
||||
|
||||
The core logic (`integrations/common`, package `net.scopenet.core`) is platform-neutral and unit-tested with `gradle -p integrations :common:test`. The Fabric classes are thin adapters over it.
|
||||
|
||||
## What the server mod adds (same behaviour as the Paper plugin)
|
||||
|
||||
* **Essentials:** `/spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime` (homes and warps saved in `config/scopenet/essentials.json`). `/back` remembers where you were before SCOPENET teleports and where you died.
|
||||
* **Economy:** `/balance /pay /baltop /shop /sell hand /market (sell|buy) /orders /transactions`. Selling and buying go through an escrow file (`economy-pending.json`) so a crash or a panel outage never loses an item or a payment.
|
||||
* **Guilds:** `/guild` (info, create, leave, claim, unclaim, map, chat, sethome, home, members, bank, pay, sell, market), `/claim`, `/unclaim`. Claim checks use the same local index as the rest of the mod.
|
||||
* **Admin:** `/scopenet help | status | reload` (alias `/sn`).
|
||||
* **Permissions:** the same `scopenet.command.*` nodes as Paper. With the LuckPerms mod installed its answer wins; otherwise `permissions.default_level` decides, and admin nodes need operator level 2. `scopenet.claims.bypass` lets staff build in other guilds' claims.
|
||||
* **Placeholder API:** `%scopenet:level%`, `%scopenet:guild%`, `%scopenet:balance%`, `%scopenet:playtime%`, `%scopenet:quest_progress%` and the rest of the Paper list (see `Placeholders.KEYS`).
|
||||
* **Economy for other mods:** `net.scopenet.fabric.api.ScopenetEconomy` (Fabric's stand-in for Vault): `balance`, `deposit`, `withdraw`, all asynchronous and safe to retry.
|
||||
|
||||
Not on Fabric yet: `/trade` (needs a chest window), item sorting of homes/warps into a menu (they are listed in chat), and teleports by other mods are not remembered for `/back`.
|
||||
|
||||
### Economy mods
|
||||
|
||||
SCOPENET keeps the ledger in the panel, so its money is one number per player across your network. Other Fabric mods can use it through `ScopenetEconomy`. Adapters that register SCOPENET as a provider inside third-party economy mods (Impactor, Common Economy API and similar) are **not included**: their APIs change between versions and I could not build against them, so shipping guesses would risk silent money bugs. They are small to add once the exact versions are chosen; the extension point is `ScopenetEconomy`.
|
||||
|
||||
## The client mod
|
||||
|
||||
* **HUD widgets:** Level (with XP bar and server level), Balance, Guild, Claim (who owns the chunk you are in) and Quests. Each can be on, compact or off.
|
||||
* **Claim borders:** green boxes around your guild's land and red around other guilds', within 4 chunks.
|
||||
* **Windows:** press **K** for the menu, then **Shop** (every price, your held item and what it is worth, one-click sell), **Market** (browse, one-click buy, list your held item) or **Settings**.
|
||||
* **HUD layout editor:** Settings → *Edit HUD layout*. Drag widgets, right-click to hide or show, scroll to resize. Positions snap to screen anchors, so they hold on any window size.
|
||||
* **Toasts:** level-ups, achievements and guild changes, each switchable.
|
||||
* **Keys:** *Open SCOPENET menu* (K) and *Show or hide SCOPENET HUD* (unbound) in Controls → SCOPENET.
|
||||
|
||||
Settings are saved to `config/scopenet-client.json` (see `examples/fabric-client/`).
|
||||
|
||||
### How it talks to the server
|
||||
|
||||
Two plugin-message channels, `scopenet:s2c` and `scopenet:c2s`, carrying JSON in a Minecraft UTF string (`net.scopenet.core.Wire`). The client says hello; the server then sends its state, the claims around the player, and answers market and shop requests. The same channels work on the Paper plugin (`clientlink.enabled` in `config.yml`), so the client mod works on both. Turn the link off with `clientlink.enabled=false`.
|
||||
|
||||
## Status
|
||||
|
||||
The shared core, the escrow flow, the client-link protocol and the placeholders are covered by unit tests that run in CI (`CommandsTest`, `LinkTest`, `EssentialsServiceTest`). **The Fabric server mod, the client mod and the Paper additions have not been compiled or run**, because the build environment they were written in cannot download Minecraft, Fabric or Paper. Build them, then report any compile errors.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Plugin integrations, developer API and network features
|
||||
|
||||
## Optional plugins (Paper)
|
||||
|
||||
SCOPENET finds these plugins by itself at startup. Nothing is hard-wired into `ScopenetPlugin`; each integration is a module in `net.scopenet.paper.compat` that is only loaded when its plugin is present. Switch any off in `config.yml` under `integrations:`. `/scopenet status` lists what is active.
|
||||
|
||||
| Plugin | What SCOPENET does |
|
||||
|---|---|
|
||||
| LuckPerms | Reports each online player's rank to the panel (shown read-only on profiles and the server page). Panel groups can be mapped to LuckPerms groups (Players → Groups). LuckPerms always decides real permissions. Sync modes: off, game→panel, panel→game, both (adds only, never removes; panel→game also needs `apply-panel-groups: true`). |
|
||||
| PlaceholderAPI | `%scopenet_level%`, `%scopenet_guild%`, `%scopenet_balance%`, `%scopenet_playtime%`, `%scopenet_quest_progress%` and more (see the expansion class for the full list). |
|
||||
| Vault | SCOPENET's economy becomes Vault's economy, so other plugins pay into the same balances. |
|
||||
| CoreProtect | Logged-activity rate, top actors and backup folder status appear on the server page. |
|
||||
| WorldGuard | Regions (area, owners, flags) appear on the server page, including server-owned territory. |
|
||||
| Spark | TPS, MSPT, CPU and memory appear on the server page. |
|
||||
|
||||
## Permissions
|
||||
|
||||
Every command has a LuckPerms-friendly node, `scopenet.command.<command>[.<subcommand>]` (for example `scopenet.command.guild.bank.withdraw`). Wildcards `scopenet.command.*` and `scopenet.command.guild.*` work. `scopenet.claims.bypass` lets staff build in other guilds' claims. All nodes are declared in `plugin.yml`.
|
||||
|
||||
## Developer API
|
||||
|
||||
Depend on the `integrations/api` module (`net.scopenet.api`). Get the API from Bukkit's services manager or `ScopenetApiProvider.get()`:
|
||||
|
||||
```java
|
||||
ScopenetApi api = ScopenetApiProvider.get();
|
||||
api.getPlayer(uuid).thenAccept(p -> ...);
|
||||
api.getGuild(uuid); api.getBalance(uuid); api.getFriends(uuid);
|
||||
api.addXP(uuid, 50, "my-plugin");
|
||||
api.completeQuestObjective(uuid, "my_action", 1);
|
||||
```
|
||||
|
||||
Bukkit events: `ScopenetLevelUpEvent`, `GuildJoinEvent`, `AchievementUnlockEvent`. They are raised on the main thread when the panel reports them.
|
||||
|
||||
## Discord
|
||||
|
||||
Players link Discord in the launcher (Settings → Connections). In panel Settings → Discord community, admins can:
|
||||
- map panel groups to Discord role IDs (Players → Groups) and pick a sync direction (adds only; also runs every 15 minutes),
|
||||
- set an invite link shown in the launcher,
|
||||
- post announcements (achievements, new guilds, new players) to a webhook.
|
||||
|
||||
## Networks
|
||||
|
||||
Servers already track a global level plus a level per server; friends, guilds and achievements are account-wide. To share money, give servers the same **Shared economy group** name in the server's settings: balances and guild banks are then one pool. Existing per-server balances are not merged when a server joins a group.
|
||||
|
||||
## Deleting accounts
|
||||
|
||||
Deleting a player in the panel removes their levels, quests, achievements, stats, friends, messages, posts, profile, economy, guild membership (leadership passes on, empty guilds are dissolved), sessions and skin file. Other players' records that mention them (transactions, bank logs) are anonymised. The name stays reserved so nobody can impersonate them. Plugin-local data such as homes lives on the game server and is not reachable from the panel.
|
||||
+48
@@ -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.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Microsoft sign-in
|
||||
|
||||
Microsoft accounts give players their real skin and let them join online-mode servers. It needs an Azure app registration that Mojang has approved for the Minecraft API.
|
||||
|
||||
## 1. Register an app
|
||||
|
||||
1. Go to [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID → App registrations → New registration**.
|
||||
2. Name: your launcher's name. Supported account types: **Personal Microsoft accounts only**.
|
||||
3. Redirect URI: leave empty.
|
||||
4. After creating it, open **Authentication** → enable **Allow public client flows** (needed for the device-code flow) → Save.
|
||||
5. Copy the **Application (client) ID**.
|
||||
|
||||
## 2. Request Minecraft API access
|
||||
|
||||
New apps can't call the Minecraft services API until Mojang approves them. Submit the form at <https://aka.ms/mce-reviewappid> with your client ID. Approval can take a while; until then sign-in fails with *"Minecraft services rejected the Xbox token"*.
|
||||
|
||||
## 3. Enable it in the panel
|
||||
|
||||
**Settings → Microsoft accounts** → turn on *Sign in with Microsoft* and paste the client ID. Launchers show the Microsoft tab on their next refresh.
|
||||
|
||||
## How it works
|
||||
|
||||
The launcher uses the OAuth **device-code flow**: it shows a short code, opens `microsoft.com/link`, and waits. The chain is Microsoft → Xbox Live → XSTS → Minecraft services → profile. The refresh token is stored encrypted on the player's PC and renewed silently before launches. The panel never sees Microsoft credentials.
|
||||
|
||||
Common errors are translated for players (no Xbox profile yet, child account, game not owned).
|
||||
@@ -0,0 +1,21 @@
|
||||
# Ranks, guild roles, chat and warps
|
||||
|
||||
## Level ranks
|
||||
|
||||
In the panel's **Leveling** page, create a **Player Title / Prefix** reward at each global level milestone. A title can name a LuckPerms group and a Discord role ID. The highest title the player has earned is shown in Paper chat and, when LuckPerms is installed, its configured group is assigned automatically. SCOPENET only changes LuckPerms groups explicitly named on title rewards. Existing panel group to LuckPerms mappings still work independently.
|
||||
|
||||
In **Settings → Discord community**, enable role sync and optionally set a base role ID. Linked players receive the Discord role for their highest earned title; players without a mapped title receive the base role. When a higher title replaces a lower one, the old managed title role is removed. Players without a linked Discord account cannot receive a Discord role; in-game chat shows the base `Novice` title until they earn a title.
|
||||
|
||||
Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname: message`. LuckPerms prefixes and suffixes decorate the name. Messages support `**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`, backtick code, and Bukkit `&` formatting. `&#RRGGBB` colors and `&` codes require `scopenet.chat.color`, which is granted to operators by default. Players set or clear a persistent nickname with `/nick <name|off>`; chat also respects display names set by other plugins.
|
||||
|
||||
## Guilds
|
||||
|
||||
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 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
|
||||
|
||||
Players use `/reqwarp <name>` at the requested location. An operator or player with `scopenet.command.warp.manage` uses `/warprequests`, then `/warpapprove <name>` or `/warpdeny <name>`. Admins can create and remove warps directly with `/setwarp <name>` and `/delwarp <name>`. Pending requests are stored in `plugins/SCOPENET/warp_requests.yml`; approved warps are stored in `warps.yml`.
|
||||
|
||||
`/hand` sends the held item as a clickable chat entry. Other players can inspect a read-only snapshot for five minutes. The launcher **Command Guide** lists these commands and their permission nodes.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Official servers and private authentication
|
||||
|
||||
Use **online-mode=true**. The server's authlib-injector agent redirects Minecraft's session verification to your panel. The plugin/mod then checks account status, groups and optional launcher access before admitting the player. Offline mode cannot verify identity and the integration refuses it.
|
||||
|
||||
## Install
|
||||
|
||||
1. Deploy the updated panel and set **Settings → Public address** (or `PUBLIC_URL`) to its HTTPS URL.
|
||||
2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token.
|
||||
3. Install one server integration from Releases:
|
||||
- Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported.
|
||||
- Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. Sign-in, stats, claims and the Live Map need no Fabric API or client mod. The 1.20.1 build adds commands, permissions, placeholders and the client link, and needs Fabric API; see [fabric.md](fabric.md).
|
||||
- Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET.
|
||||
- Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds.
|
||||
4. Configure Paper at `plugins/SCOPENET/config.yml`:
|
||||
|
||||
```yaml
|
||||
panel-url: "https://panel.example.com"
|
||||
token: "sn_COPY_THE_SERVER_TOKEN_HERE"
|
||||
```
|
||||
|
||||
Fabric/Forge use `config/scopenet.properties`:
|
||||
|
||||
```properties
|
||||
panel-url=https://panel.example.com
|
||||
token=sn_COPY_THE_SERVER_TOKEN_HERE
|
||||
```
|
||||
|
||||
5. Download `https://panel.example.com/api/v1/launcher/authlib-injector.jar` beside the server JAR. Add the JVM flag **before** `-jar`:
|
||||
|
||||
```sh
|
||||
java -javaagent:authlib-injector.jar=https://panel.example.com/api/yggdrasil -Xmx4G -jar server.jar nogui
|
||||
```
|
||||
|
||||
For Forge's generated `run.sh`/`run.bat`, put the same `-javaagent:…` line in `user_jvm_args.txt`.
|
||||
6. Keep `online-mode=true` in `server.properties`, restart, and sign in through the launcher. Java requirements: 17+ for 1.20.1, 21+ for 1.21.1, and 25+ for 26.x.
|
||||
|
||||
Missing/invalid configuration blocks new logins (Paper) or aborts startup (mods). Network errors, invalid responses, exhausted login workers and timeouts deny admission. Login requests are bounded and do not block the server tick. Existing players are not disconnected solely because of a temporary panel outage; revocations resume when heartbeats recover.
|
||||
|
||||
## Permanent identity and impersonation protection
|
||||
|
||||
- New panel accounts receive a random UUID once. The database rejects subsequent UUID changes.
|
||||
- Existing accounts retain their original UUID, including UUIDs migrated from the old offline-account scheme. **Never delete player data or recalculate UUIDs when renaming.**
|
||||
- Launcher **Settings → Skin & cape** supports skin upload/reset, arm model, permitted capes and password-confirmed username changes. Renames keep the account UUID, permissions, skins/capes and UUID-keyed Minecraft inventory/advancements. Old names stay reserved to that identity, even after account deletion.
|
||||
- Renaming invalidates old panel/game sessions and creates a new session for the requesting launcher. Players must close Minecraft first. Plugins that store data by *name* instead of UUID may need their own migration; SCOPENET cannot rewrite third-party storage.
|
||||
- Server login authorization uses the session-authenticated UUID. A matching name never grants another account's access. Tokens are server-specific; rotate one from the panel if exposed.
|
||||
- Use HTTPS for the panel. Keep its database, JWT secret and `yggdrasil-signing.pem` private and backed up. Do not expose an offline-mode backend through a proxy: this release supports directly authenticated servers, not proxy-forwarded identities.
|
||||
- Forwarded IP headers are ignored unless the socket peer is listed in `SCOPENET_TRUSTED_PROXIES` (comma-separated literal IPs). Configure your reverse proxy to replace/append forwarded addresses and restrict direct access to the backend. Set `PUBLIC_URL`; do not derive production URLs from untrusted request headers.
|
||||
- **Require launcher** checks for a recent authenticated launch from the same IP. It is an additional access rule, not proof of an unmodified launcher: a modified authenticated client can reproduce its HTTP request. Password/session verification is the identity boundary.
|
||||
|
||||
## Activity recorded
|
||||
|
||||
The panel's **Activity** page filters by source and username/UUID. **Servers** shows online players, estimated TPS, playtime, leaderboards and recent server events.
|
||||
|
||||
- All integrations: joins/leaves, elapsed playtime, deaths, player/mob kills, mined/placed blocks, **chat counts only**, command **names only**, inventory-click activity, advancement activity and vanilla statistic increments (such as crafting, item use/pickup/drop, movement and damage). Repeated statistic increments are grouped per reporting interval.
|
||||
- Paper additionally records inventory action/slot/item metadata, advancement names and teleport causes/destinations through Bukkit events. Fabric/Forge record advancement criteria and inventory-click counts through Minecraft hooks.
|
||||
- Launcher: opening/closing through the launcher UI, account selection/sign-out, settings changes, install/repair start/completion, launch/cancel/failure, game exit/crash/termination, instance deletion, cache clearing, opening folders/links and update installation. Account/profile/admin changes and successful sign-ins are audited by the panel. Launcher identity comes from the bearer token, never a submitted username.
|
||||
- No chat text, private messages, command arguments, passwords, tokens, game logs or local paths are uploaded. Launcher records are client-reported; server records are authenticated with the server token.
|
||||
|
||||
This is an activity log, not a packet/world replay or block rollback system. Actions performed internally by other mods/plugins without vanilla statistics or the covered event hooks need additional adapters. Entity AI, redstone ticks and every movement packet are not individually recorded.
|
||||
|
||||
Heartbeats run every 30 seconds. Failed server batches are retried with a stable ID and transactionally deduplicated by the panel. Queues are bounded; overload emits `telemetry_gap` rather than silently pretending coverage is complete. A clean stop attempts a final flush. Unsent data is memory-resident, so crashes or a prolonged outage can lose queued activity. Launcher reports are best-effort, and game-exit/crash reports are unavailable if **Close launcher** was selected. Server event retention is 60 days; sync receipts remain until the server entry is deleted.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Use Java 21 + Gradle 8.10.2 for legacy builds, Java 25 + Gradle 9.6.0 for 26.x:
|
||||
|
||||
```sh
|
||||
gradle -p integrations :common:test :paper:build
|
||||
gradle -p integrations -Ploader=fabric -PmcVersion=1.20.1 :fabric:build
|
||||
gradle -p integrations -Ploader=forge -PmcVersion=1.21.1 :forge:build
|
||||
gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build
|
||||
```
|
||||
|
||||
The optional Fabric client mod builds with `-Ploader=fabric-client :fabric-client:build`. Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The locally built 0.4.0 matrix for 1.20.1, 1.21.1, and 26.3 plus Paper is in [`release-artifacts/0.4.0`](../release-artifacts/0.4.0/README.md), with SHA-256 checksums. The **Server integrations** workflow can rebuild the matrix when Actions usage is available again.
|
||||
|
||||
Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Example configuration
|
||||
|
||||
Copy the file for your setup and fill in `panel-url` and `token` (create the token under **Servers** in the admin panel).
|
||||
|
||||
| Folder | Goes in | For |
|
||||
|---|---|---|
|
||||
| `paper/config.yml` | `plugins/SCOPENET/config.yml` | The Paper plugin. Includes the `integrations:` section for LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard and Spark. |
|
||||
| `fabric-server/scopenet.properties` | `config/scopenet.properties` | The Fabric server mod (Fabric 1.20.1, 1.21.1). |
|
||||
| `fabric-client/scopenet-client.json` | `config/scopenet-client.json` | The optional Fabric client mod: HUD widgets, notifications and keybinds. |
|
||||
|
||||
The client mod is display-only. The server mod or plugin stays in charge of claims, money and permissions, so a missing or edited client mod can't change anything.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"hud": {
|
||||
"scale": 1.0,
|
||||
"opacity": 0.85,
|
||||
"hideInF3": true,
|
||||
"hideWhileChatOpen": false,
|
||||
"claimBorders": true,
|
||||
"claimBordersOnlyWhenSneaking": false,
|
||||
"widgets": {
|
||||
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "compact": false },
|
||||
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
|
||||
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -40, "compact": false },
|
||||
"claim": { "enabled": true, "anchor": "top_center", "x": 0, "y": 8, "compact": false },
|
||||
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "compact": false }
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"levelUp": true,
|
||||
"achievements": true,
|
||||
"guild": true,
|
||||
"errors": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# SCOPENET for Fabric (server side) -> config/scopenet.properties
|
||||
# Same panel as the Paper plugin. Sign-in, stats, claims and the Live Map work on every supported Fabric version.
|
||||
# Commands, permissions, placeholders and the client link need Fabric API and are in the Fabric 1.20.1 build.
|
||||
# Extra files the mod creates: config/scopenet/essentials.json (homes, warps), guild-homes.json, economy-pending.json.
|
||||
|
||||
# --- Connection (required) ---------------------------------------------------
|
||||
# Your panel address and the token from Servers -> this server in the panel.
|
||||
panel-url=https://panel.example.com
|
||||
token=
|
||||
|
||||
# --- Feature toggles (same meaning as the Paper plugin) ----------------------
|
||||
leveling.enabled=true
|
||||
leveling.global_xp_multiplier=1.0
|
||||
leveling.server_xp_multiplier=1.5
|
||||
quests.enabled=true
|
||||
achievements.enabled=true
|
||||
guilds.enabled=true
|
||||
guilds.land_claiming=true
|
||||
social.enabled=true
|
||||
social.chat_prefixes=true
|
||||
essentials.enabled=true
|
||||
economy.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.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.
|
||||
economy.currency_symbol=$
|
||||
|
||||
# --- Permissions (1.20.1) ----------------------------------------------------
|
||||
# With the LuckPerms mod installed, its answer wins. Use the same nodes as the Paper plugin
|
||||
# (scopenet.command.home, scopenet.command.guild.bank.withdraw, scopenet.claims.bypass ...).
|
||||
# Without LuckPerms, or when LuckPerms has no setting for a node:
|
||||
# all : every player may use ordinary commands
|
||||
# op : only operators may
|
||||
# Admin nodes (/scopenet status and reload, scopenet.claims.bypass) always need operator level 2 by default.
|
||||
permissions.default_level=all
|
||||
|
||||
# --- Placeholder API (1.20.1) ------------------------------------------------
|
||||
# With Placeholder API installed, registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%,
|
||||
# %scopenet:playtime%, %scopenet:quest_progress% and the rest of the Paper placeholder list.
|
||||
placeholders.enabled=true
|
||||
|
||||
# --- Client mod link (1.20.1) ------------------------------------------------
|
||||
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
|
||||
clientlink.enabled=true
|
||||
|
||||
# --- 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}
|
||||
@@ -0,0 +1,172 @@
|
||||
# ==============================================================================
|
||||
# SCOPENET Paper plugin -> plugins/SCOPENET/config.yml
|
||||
# Fully commented example. Every key shown here is read by the plugin; the values
|
||||
# are the defaults. Remove anything you don't want to change.
|
||||
# Requires Paper 1.20+ and online-mode=true in server.properties.
|
||||
# ==============================================================================
|
||||
|
||||
# --- Connection (required) ----------------------------------------------------
|
||||
# The panel's address, with no trailing path. Use the address players and servers
|
||||
# can reach, normally the same HTTPS address you set as the panel's Public address.
|
||||
panel-url: "https://panel.example.com"
|
||||
|
||||
# The server token. In the panel: Servers -> your server -> Generate token.
|
||||
# It starts with "sn_". Keep it secret; anyone with it can act as this server.
|
||||
token: ""
|
||||
|
||||
# ==============================================================================
|
||||
# Feature toggles
|
||||
# Turn off anything you don't want on this server. Changing a value needs
|
||||
# "/scopenet reload" or a restart. The panel can still switch features off for you.
|
||||
# ==============================================================================
|
||||
|
||||
leveling:
|
||||
# Global and per-server levels, earned from playtime, kills, mining and more.
|
||||
enabled: true
|
||||
# Speed of the account-wide Global Level. 1.0 is normal, 2.0 is double.
|
||||
global-xp-multiplier: 1.0
|
||||
# Speed of this server's own Server Level. Higher than global by default so
|
||||
# a server's level feels alive.
|
||||
server-xp-multiplier: 1.5
|
||||
|
||||
quests:
|
||||
# Daily and weekly quests. How many are offered is set in the panel
|
||||
# (Progression -> Quest limits).
|
||||
enabled: true
|
||||
|
||||
achievements:
|
||||
# Achievement tracking and unlock notifications.
|
||||
enabled: true
|
||||
|
||||
guilds:
|
||||
# Guilds, /guild commands, the guild bank and guild market.
|
||||
enabled: true
|
||||
# Guild land claims. Other players can't build or break in a claimed chunk
|
||||
# unless they are in that guild (or have scopenet.claims.bypass).
|
||||
# Claims are checked against a local copy that refreshes from the panel, so
|
||||
# breaking blocks never waits on the network.
|
||||
land-claiming: true
|
||||
# Let guild members hurt each other.
|
||||
friendly-fire: false
|
||||
|
||||
social:
|
||||
# Friends, direct messages and game invites.
|
||||
enabled: true
|
||||
# Show guild tag and level title in chat.
|
||||
chat-prefixes: true
|
||||
|
||||
essentials:
|
||||
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||
enabled: true
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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
|
||||
enabled: true
|
||||
# Shown next to amounts.
|
||||
currency-symbol: "$"
|
||||
|
||||
# ==============================================================================
|
||||
# Optional plugin integrations
|
||||
# SCOPENET finds these plugins by itself. Switch one off here if you don't want it.
|
||||
# "/scopenet status" shows which are active.
|
||||
# ==============================================================================
|
||||
integrations:
|
||||
luckperms:
|
||||
enabled: true
|
||||
# How often each online player's LuckPerms rank is sent to the panel, which
|
||||
# shows it on profiles and the server page.
|
||||
report-interval-seconds: 60
|
||||
# Let the panel add or remove LuckPerms groups for players, for groups an admin
|
||||
# mapped in Panel -> Players -> Groups. LuckPerms still decides real permissions.
|
||||
# Off by default: turn it on only when you want panel groups to change ranks here.
|
||||
apply-panel-groups: false
|
||||
placeholderapi:
|
||||
# Adds %scopenet_level%, %scopenet_guild%, %scopenet_balance%, %scopenet_playtime%,
|
||||
# %scopenet_quest_progress% and more for scoreboards, tab lists, holograms and chat.
|
||||
enabled: true
|
||||
vault:
|
||||
# Make SCOPENET's economy the Vault economy, so shop, jobs and other plugins
|
||||
# use the same balances.
|
||||
enabled: true
|
||||
coreprotect:
|
||||
# Show logging activity and backup status in the panel.
|
||||
enabled: true
|
||||
# Folders (relative to the server folder) checked for world backups.
|
||||
backup-folders:
|
||||
- backups
|
||||
worldguard:
|
||||
# Show WorldGuard regions, including server-owned territory, in the panel.
|
||||
enabled: true
|
||||
report-interval-minutes: 5
|
||||
spark:
|
||||
# Show TPS, MSPT, CPU and memory in the panel.
|
||||
enabled: true
|
||||
report-interval-seconds: 30
|
||||
|
||||
# ==============================================================================
|
||||
# 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}.
|
||||
# ==============================================================================
|
||||
rewards:
|
||||
enabled: true
|
||||
# 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
|
||||
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
|
||||
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
|
||||
# are unaffected. Display only: everything the client does is an ordinary command.
|
||||
# ==============================================================================
|
||||
clientlink:
|
||||
enabled: true
|
||||
|
||||
# ==============================================================================
|
||||
# Permissions (set these in LuckPerms, not here)
|
||||
# scopenet.command.<command> e.g. scopenet.command.home
|
||||
# scopenet.command.guild.* every /guild subcommand
|
||||
# scopenet.command.* everything
|
||||
# scopenet.claims.bypass build in other guilds' claims (staff)
|
||||
# scopenet.admin /scopenet status and reload
|
||||
# All nodes are listed in the plugin's plugin.yml.
|
||||
# ==============================================================================
|
||||
@@ -0,0 +1,19 @@
|
||||
const fs=require('fs');function edit(p,f){fs.writeFileSync(p,f(fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n')))}
|
||||
edit('panel/server/src/routes/economy.rs',s=>{
|
||||
s=s.replace('if payload.amount <= 0.0 {','if !payload.amount.is_finite() || payload.amount <= 0.0 {');
|
||||
s=s.replace('pub struct MarketListPayload {','pub struct MarketListPayload {\n pub operation_id: String,\n pub item_data: Option<String>,');
|
||||
const a=s.indexOf('pub async fn server_market_list('),b=s.indexOf('#[derive(Deserialize)]\npub struct MarketBuyPayload',a);
|
||||
let seg=s.slice(a,b).replace(' let now =', ` if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 { return Err(AppError::bad_request("Invalid listing")); }
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||
let now =`).replace('amount, price, created_at)','amount, price, created_at, item_data)').replace('VALUES (?, ?, ?, ?, ?, ?, ?, ?)','VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').replace('.bind(&now)\n .fetch_one(&state.db)', '.bind(&now)\n .bind(&payload.item_data)\n .fetch_one(&mut *tx)').replace('Ok(Json(serde_json::json!({ "id": id, "ok": true })))','finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({ "id": id, "ok": true })).await');
|
||||
s=s.slice(0,a)+seg+s.slice(b);
|
||||
s=s.replace('pub struct MarketBuyPayload {','pub struct MarketBuyPayload {\n pub operation_id: String,');
|
||||
const c=s.indexOf('pub async fn server_market_buy(');
|
||||
seg=s.slice(c).replace(' let listing:', ` let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||
let listing:`).replace('String, String, String, String, i32, f64)>','String, String, String, String, i32, f64, Option<String>)>').replace('SELECT seller_uuid, seller_name, item_id, item_name, amount, price','DELETE FROM server_market\n WHERE id = ? AND server_id = ?\n RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data').replace(' FROM server_market\n WHERE id = ? AND server_id = ?', '').replace('.fetch_optional(&state.db)', '.fetch_optional(&mut *tx)').replace('item_name, amount, price))','item_name, amount, price, item_data))').replace(' let mut tx = state.db.begin().await?;', ' ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;');
|
||||
seg=seg.replace(' tx.commit().await?;\n\n Ok(Json(serde_json::json!({',' finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({').replace(' "item_id": item_id,',' "item_id": item_id,\n "item_data": item_data,').replace(' })))\n}', ' })).await\n}');
|
||||
return s.slice(0,c)+seg;
|
||||
});
|
||||
edit('panel/server/src/routes/mod.rs',s=>s.replace(' .route("/economy/sync-balance",', ' .route("/economy/adjust", post(economy::server_adjust_balance))\n .route("/economy/market", post(economy::server_market_read))\n .route("/economy/sync-balance",'));
|
||||
@@ -0,0 +1,47 @@
|
||||
const fs = require('fs');
|
||||
function edit(p, fn) { const s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n'); fs.writeFileSync(p,fn(s)); }
|
||||
function rep(s,a,b) { if(!s.includes(a)) throw new Error('Missing: '+a.slice(0,100)); return s.replace(a,b); }
|
||||
edit('launcher/src-tauri/src/telemetry.rs',s=>rep(rep(s,'let accounts = state.accounts.read().unwrap();','let token = accounts::panel_token(state)?;\n let accounts = state.accounts.read().unwrap();'),' let token = accounts::panel_token(state);','').replace(' token,',' token: Some(token),'));
|
||||
edit('panel/server/src/routes/servers.rs',s=>{
|
||||
const start=s.indexOf('fn is_private_or_local'); const end=s.indexOf('async fn verify_launcher_ip',start);
|
||||
return s.slice(0,start)+`pub fn ips_match(sess_str: &str, req_str: &str) -> bool {
|
||||
match (sess_str.trim().parse::<std::net::IpAddr>(), req_str.trim().parse::<std::net::IpAddr>()) {
|
||||
(Ok(a), Ok(b)) => canonical_ip(a) == canonical_ip(b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
`+s.slice(end).replace('assert!(ips_match("127.0.0.1", "::1"))','assert!(!ips_match("127.0.0.1", "::1"))').replace('assert!(ips_match("::1", "127.0.0.1"))','assert!(!ips_match("::1", "127.0.0.1"))').replace('assert!(ips_match("172.18.0.1", "192.168.1.50"))','assert!(!ips_match("172.18.0.1", "192.168.1.50"))').replace('assert!(ips_match("10.0.0.1", "10.0.0.2"))','assert!(!ips_match("10.0.0.1", "10.0.0.2"))').replace('assert!(ips_match("203.0.113.5", "203.0.113.9"))','assert!(!ips_match("203.0.113.5", "203.0.113.9"))');
|
||||
});
|
||||
edit('launcher/src/pages/Settings.svelte',s=>rep(s,"{@const isCurrent = profile.skin_model === sp.skin_model && (sp.cape_id === (profile.cape?.id ?? null))}","{@const isCurrent = !!sp.skin_data && sp.skin_data === profile.skin_url && profile.skin_model === sp.skin_model && sp.cape_id === (profile.cape?.id ?? null)}"));
|
||||
edit('launcher/src/pages/Social.svelte',s=>s.replaceAll('pending_received','pending_incoming').replaceAll('pending_sent','pending_outgoing').replaceAll('.is_online','.online').replaceAll('toUuid: activeChatFriend.uuid','friendUuid: activeChatFriend.uuid').replaceAll('toUuid: inviteTargetFriend.uuid','recipientUuid: inviteTargetFriend.uuid').replaceAll('.from_uuid','.sender_uuid').replaceAll('.from_username','.sender_name').replaceAll('.to_uuid','.recipient_uuid').replaceAll('.server_address','.instance_name'));
|
||||
edit('launcher/src/lib/types.ts',s=>{
|
||||
s=s.replace("'pending_sent' | 'pending_received'", "'pending_outgoing' | 'pending_incoming'").replace(' is_online: boolean;',' online: boolean;');
|
||||
s=s.replaceAll(' from_uuid: string;',' sender_uuid: string;').replaceAll(' to_uuid: string;',' recipient_uuid: string;').replace(' from_username: string;',' sender_name: string;').replace(' server_address: string;',' instance_name: string;');
|
||||
s=rep(s,'export interface GuildMember {\n guild_id: string;\n user_uuid: string;\n username: string;', 'export interface GuildMember {\n uuid: string;\n name: string;').replace(' is_online?: boolean;',' online?: boolean;');
|
||||
s=s.replace('export interface GuildPost {\n id: string;', 'export interface GuildPost {\n id: number;').replace('export interface UserPost {\n id: string;', 'export interface UserPost {\n id: number;');
|
||||
s=rep(s,'export interface GuildClaim {\n id: number;\n guild_id: string;\n instance_id: string;', 'export interface GuildClaim {\n id: number;\n guild_id: string;\n server_id: number;');
|
||||
s=rep(s,' global_level: UserLevelInfo;', ' global_level: number;\n global_xp: number;\n title: string | null;\n level_info: UserLevelInfo;');
|
||||
return s;
|
||||
});
|
||||
edit('launcher/src/pages/Guilds.svelte',s=>s.replaceAll('m.user_uuid','m.uuid').replaceAll('m.username','m.name').replaceAll('m.is_online','m.online').replace(' / {myGuild.max_members} Members',' Members'));
|
||||
edit('launcher/src/components/PlayerProfileModal.svelte',s=>s.replaceAll('profile.global_level.', 'profile.level_info.').replaceAll('profile.global_level?.', 'profile.level_info?.').replace("bio: editBio.trim() || null", "bio: editBio.trim()").replace(" const liked = await invoke<boolean>('like_user_post', { postId: post.id });\n post.liked_by_me = liked;\n post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1);", " await invoke('like_user_post', { postId: post.id });\n post.likes_count += 1;"));
|
||||
edit('launcher/src-tauri/src/commands.rs',s=>{
|
||||
s=rep(s,'pub async fn send_game_invite(\n', 'pub async fn send_game_invite(\n');
|
||||
const a=s.indexOf('pub async fn send_game_invite('), b=s.indexOf('#[tauri::command]',a);
|
||||
let seg=s.slice(a,b).replace(') -> Res<GameInvite>',') -> Res<()>').replace(' resp.json().await.map_err(err)',' Ok(())'); s=s.slice(0,a)+seg+s.slice(b);
|
||||
s=s.replace('json!({ "accept": accept })','json!({ "action": if accept { "accept" } else { "decline" } })');
|
||||
const c=s.indexOf('pub async fn update_my_profile('), d=s.indexOf('#[tauri::command]',c);
|
||||
seg=s.slice(c,d).replace(' resp.json().await.map_err(err)', ' account_api(&state, reqwest::Method::GET, "/auth/me").await?\n .send().await.map_err(err)?\n .json::<serde_json::Value>().await.map_err(err)\n .and_then(|v| v["uuid"].as_str().map(str::to_owned).ok_or("missing account UUID".into()))\n .map(|uuid| (state, uuid))?;');
|
||||
// Fetch the updated public profile using the selected account UUID.
|
||||
seg=s.slice(c,d).replace(' resp.json().await.map_err(err)', ' let uuid = state.accounts.read().unwrap().active().ok_or("no active account")?.uuid.clone();\n get_user_profile(state, uuid).await'); s=s.slice(0,c)+seg+s.slice(d);
|
||||
s=s.replace('reqwest::Method::POST, "/posts"','reqwest::Method::POST, "/profiles/me/posts"');
|
||||
return s;
|
||||
});
|
||||
edit('panel/server/src/routes/social.rs',s=>rep(s,' let rows: Vec<(i64, String, String, String, String, bool, String)>', ' let mut rows: Vec<(i64, String, String, String, String, bool, String)>').replace('ORDER BY id ASC LIMIT 100','ORDER BY id DESC LIMIT 100').replace(' // Mark unread messages sent to me as read',' rows.reverse();\n\n // Mark unread messages sent to me as read'));
|
||||
edit('panel/web/src/pages/AchievementEditor.svelte',s=>rep(s," stat_type: a.stat_type", " requirement_type: a.requirement_type || 'stat',\n stat_type: a.stat_type"));
|
||||
edit('panel/web/src/lib/types.ts',s=>rep(s,'export interface Achievement {','export interface Achievement {\n requirement_type?: string;'));
|
||||
edit('panel/server/src/routes/achievements.rs',s=>{
|
||||
const a=s.indexOf('pub async fn admin_update_achievement');
|
||||
return s.slice(0,a)+s.slice(a).replace('let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());','let req_type = payload.requirement_type;').replace('requirement_type = ?,','requirement_type = COALESCE(?, requirement_type),');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// The public SCOPENET API: depend on this (compileOnly) from your own plugin.
|
||||
// It is bundled inside the SCOPENET plugin jar, so there is nothing to shade.
|
||||
plugins { id 'java-library' }
|
||||
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
|
||||
dependencies {
|
||||
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
/** A player's guild, as seen from the server they are on. */
|
||||
public record GuildSummary(String id, String name, String tag, String role, int claims) {
|
||||
public boolean isLeader() { return "leader".equalsIgnoreCase(role); }
|
||||
public boolean isOfficer() { return isLeader() || "officer".equalsIgnoreCase(role); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
/** How many of a player's quests for the current period they have finished. */
|
||||
public record QuestProgress(int total, int completed, int claimed) {
|
||||
/** "3/5", handy for scoreboards. */
|
||||
public String display() { return completed + "/" + total; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* The SCOPENET developer API. Everything that talks to the panel returns a
|
||||
* {@link CompletableFuture} and never blocks the server thread; the
|
||||
* {@code getCached…} methods answer instantly from data SCOPENET already holds.
|
||||
* Futures complete on a worker thread, so hop back with the Bukkit scheduler
|
||||
* before touching the world.
|
||||
*
|
||||
* <pre>{@code
|
||||
* ScopenetApi api = ScopenetApiProvider.get();
|
||||
* api.getPlayer(player.getUniqueId()).thenAccept(p -> getLogger().info(p.name() + " is level " + p.globalLevel()));
|
||||
* api.addXP(player.getUniqueId(), XpScope.SERVER, 250, "boss kill");
|
||||
* }</pre>
|
||||
*
|
||||
* Events ({@link net.scopenet.api.event.ScopenetLevelUpEvent},
|
||||
* {@link net.scopenet.api.event.GuildJoinEvent},
|
||||
* {@link net.scopenet.api.event.GuildLeaveEvent},
|
||||
* {@link net.scopenet.api.event.AchievementUnlockEvent}) are ordinary Bukkit events.
|
||||
*/
|
||||
public interface ScopenetApi {
|
||||
/** Bumped when the API changes incompatibly. */
|
||||
int API_VERSION = 1;
|
||||
|
||||
/** Load a player's SCOPENET profile. Completes with empty if they have no SCOPENET account. */
|
||||
CompletableFuture<Optional<ScopenetPlayer>> getPlayer(UUID uuid);
|
||||
|
||||
/** The last profile loaded for a player (kept fresh for online players), if any. Never blocks. */
|
||||
Optional<ScopenetPlayer> getCachedPlayer(UUID uuid);
|
||||
|
||||
/** Find a guild on this server's instance by id, tag or name. */
|
||||
CompletableFuture<Optional<ScopenetGuild>> getGuild(String idTagOrName);
|
||||
|
||||
/** The guild a player belongs to. */
|
||||
CompletableFuture<Optional<ScopenetGuild>> getPlayerGuild(UUID uuid);
|
||||
|
||||
/** A player's balance on this server's economy. */
|
||||
CompletableFuture<Double> getBalance(UUID uuid);
|
||||
|
||||
/**
|
||||
* Give (positive) or take (negative) money. Completes with the new balance, or
|
||||
* exceptionally if the player can't afford it.
|
||||
*/
|
||||
CompletableFuture<Double> addBalance(UUID uuid, String playerName, double amount, String reason);
|
||||
|
||||
/** Give (positive) or take (negative) XP. Level-ups fire {@link net.scopenet.api.event.ScopenetLevelUpEvent}. */
|
||||
CompletableFuture<XpResult> addXP(UUID uuid, XpScope scope, long amount, String reason);
|
||||
|
||||
/**
|
||||
* Move a quest forward. {@code objective} is a quest id (with {@code amount} 0 meaning "complete it")
|
||||
* or an action like {@code block_broken:DIAMOND_ORE} that any matching quest counts.
|
||||
* Completes with whether any quest advanced.
|
||||
*/
|
||||
CompletableFuture<Boolean> completeQuestObjective(UUID uuid, String objective, int amount);
|
||||
|
||||
/** A player's accepted friends. */
|
||||
CompletableFuture<List<ScopenetFriend>> getFriends(UUID uuid);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
/** Where other plugins find the API. Also registered with Bukkit's ServicesManager. */
|
||||
public final class ScopenetApiProvider {
|
||||
private static volatile ScopenetApi api;
|
||||
|
||||
private ScopenetApiProvider() {}
|
||||
|
||||
/** @throws IllegalStateException if SCOPENET isn't loaded (add it to your plugin's depend/softdepend) */
|
||||
public static ScopenetApi get() {
|
||||
ScopenetApi current = api;
|
||||
if (current == null) throw new IllegalStateException("SCOPENET is not enabled");
|
||||
return current;
|
||||
}
|
||||
|
||||
public static boolean isAvailable() { return api != null; }
|
||||
|
||||
/** Called by SCOPENET. */
|
||||
public static void register(ScopenetApi implementation) { api = implementation; }
|
||||
|
||||
/** Called by SCOPENET when it shuts down. */
|
||||
public static void unregister(ScopenetApi implementation) {
|
||||
if (api == implementation) api = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** An accepted friend, and where they are playing right now (if anywhere). */
|
||||
public record ScopenetFriend(UUID uuid, String name, boolean online, String playingOn) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A guild and its members. Balances and claim counts are for the server the plugin runs on. */
|
||||
public record ScopenetGuild(String id, String name, String tag, String description, UUID leader, int claims, double balance, List<Member> members) {
|
||||
public record Member(UUID uuid, String name, String role) {}
|
||||
|
||||
public boolean isMember(UUID player) {
|
||||
return members.stream().anyMatch(m -> m.uuid().equals(player));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A snapshot of one SCOPENET account. It is a copy: fetch a new one (or use the
|
||||
* cached copy, which is refreshed every few seconds for online players) to see changes.
|
||||
*
|
||||
* @param rankTitle the title earned from global level rewards, or {@code null}
|
||||
* @param leaderboard the position on the global leaderboard, or 0 when unranked
|
||||
* @param rankGroup the player's LuckPerms primary group as reported by the server, or {@code null}
|
||||
* @param guild the player's guild on this server's instance, or {@code null}
|
||||
*/
|
||||
public record ScopenetPlayer(
|
||||
UUID uuid,
|
||||
String name,
|
||||
int globalLevel,
|
||||
long globalXp,
|
||||
long globalLevelXp,
|
||||
long globalNextLevelXp,
|
||||
double globalProgress,
|
||||
String rankTitle,
|
||||
int leaderboard,
|
||||
int serverLevel,
|
||||
long serverXp,
|
||||
String serverRank,
|
||||
GuildSummary guild,
|
||||
double balance,
|
||||
long playtimeSeconds,
|
||||
long serverPlaytimeSeconds,
|
||||
int kills,
|
||||
int deaths,
|
||||
int friends,
|
||||
int achievements,
|
||||
QuestProgress dailyQuests,
|
||||
QuestProgress weeklyQuests,
|
||||
String joined,
|
||||
String rankGroup,
|
||||
String rankDisplay,
|
||||
String rankPrefix,
|
||||
String rankSuffix) {
|
||||
|
||||
public boolean inGuild() { return guild != null; }
|
||||
|
||||
public double killDeathRatio() { return deaths == 0 ? kills : (double) kills / deaths; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
/** What a player's XP and level are after {@link ScopenetApi#addXP}. */
|
||||
public record XpResult(XpScope scope, long xp, int level, int previousLevel) {
|
||||
public boolean leveledUp() { return level > previousLevel; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.scopenet.api;
|
||||
|
||||
/** Which level track an XP change applies to. */
|
||||
public enum XpScope {
|
||||
/** The account-wide SCOPENET level, shared by every server. */
|
||||
GLOBAL,
|
||||
/** The level on the server the plugin is running on. */
|
||||
SERVER
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.api.event;
|
||||
|
||||
import org.bukkit.event.HandlerList;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A player unlocked a SCOPENET achievement. */
|
||||
public final class AchievementUnlockEvent extends ScopenetEvent {
|
||||
private static final HandlerList HANDLERS = new HandlerList();
|
||||
private final String achievementId;
|
||||
private final String title;
|
||||
private final long xp;
|
||||
|
||||
public AchievementUnlockEvent(UUID playerId, String achievementId, String title, long xp) {
|
||||
super(playerId);
|
||||
this.achievementId = achievementId;
|
||||
this.title = title;
|
||||
this.xp = xp;
|
||||
}
|
||||
|
||||
public String getAchievementId() { return achievementId; }
|
||||
public String getTitle() { return title; }
|
||||
/** XP the achievement was worth. */
|
||||
public long getXp() { return xp; }
|
||||
|
||||
@Override public HandlerList getHandlers() { return HANDLERS; }
|
||||
public static HandlerList getHandlerList() { return HANDLERS; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.scopenet.api.event;
|
||||
|
||||
import org.bukkit.event.HandlerList;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A player founded or joined a guild. */
|
||||
public final class GuildJoinEvent extends ScopenetEvent {
|
||||
private static final HandlerList HANDLERS = new HandlerList();
|
||||
private final String guildId;
|
||||
private final String guildName;
|
||||
private final String guildTag;
|
||||
private final String role;
|
||||
|
||||
public GuildJoinEvent(UUID playerId, String guildId, String guildName, String guildTag, String role) {
|
||||
super(playerId);
|
||||
this.guildId = guildId;
|
||||
this.guildName = guildName;
|
||||
this.guildTag = guildTag;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String getGuildId() { return guildId; }
|
||||
public String getGuildName() { return guildName; }
|
||||
public String getGuildTag() { return guildTag; }
|
||||
/** {@code leader}, {@code officer} or {@code member}. */
|
||||
public String getRole() { return role; }
|
||||
|
||||
@Override public HandlerList getHandlers() { return HANDLERS; }
|
||||
public static HandlerList getHandlerList() { return HANDLERS; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.api.event;
|
||||
|
||||
import org.bukkit.event.HandlerList;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A player left (or was removed from) a guild. */
|
||||
public final class GuildLeaveEvent extends ScopenetEvent {
|
||||
private static final HandlerList HANDLERS = new HandlerList();
|
||||
private final String guildId;
|
||||
private final String guildName;
|
||||
private final String guildTag;
|
||||
|
||||
public GuildLeaveEvent(UUID playerId, String guildId, String guildName, String guildTag) {
|
||||
super(playerId);
|
||||
this.guildId = guildId;
|
||||
this.guildName = guildName;
|
||||
this.guildTag = guildTag;
|
||||
}
|
||||
|
||||
public String getGuildId() { return guildId; }
|
||||
/** May be {@code null} if the guild no longer exists. */
|
||||
public String getGuildName() { return guildName; }
|
||||
public String getGuildTag() { return guildTag; }
|
||||
|
||||
@Override public HandlerList getHandlers() { return HANDLERS; }
|
||||
public static HandlerList getHandlerList() { return HANDLERS; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.api.event;
|
||||
|
||||
import org.bukkit.event.Event;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Base class for SCOPENET events. They are fired on the server thread when the
|
||||
* panel reports a change for a player who is on this server.
|
||||
*/
|
||||
public abstract class ScopenetEvent extends Event {
|
||||
private final UUID playerId;
|
||||
|
||||
protected ScopenetEvent(UUID playerId) {
|
||||
this.playerId = playerId;
|
||||
}
|
||||
|
||||
public UUID getPlayerId() { return playerId; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.scopenet.api.event;
|
||||
|
||||
import net.scopenet.api.XpScope;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A player's global or server level went up. */
|
||||
public final class ScopenetLevelUpEvent extends ScopenetEvent {
|
||||
private static final HandlerList HANDLERS = new HandlerList();
|
||||
private final XpScope scope;
|
||||
private final int level;
|
||||
private final int previousLevel;
|
||||
private final long xp;
|
||||
|
||||
public ScopenetLevelUpEvent(UUID playerId, XpScope scope, int level, int previousLevel, long xp) {
|
||||
super(playerId);
|
||||
this.scope = scope;
|
||||
this.level = level;
|
||||
this.previousLevel = previousLevel;
|
||||
this.xp = xp;
|
||||
}
|
||||
|
||||
public XpScope getScope() { return scope; }
|
||||
public int getLevel() { return level; }
|
||||
public int getPreviousLevel() { return previousLevel; }
|
||||
public long getXp() { return xp; }
|
||||
|
||||
@Override public HandlerList getHandlers() { return HANDLERS; }
|
||||
public static HandlerList getHandlerList() { return HANDLERS; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
allprojects {
|
||||
group = 'net.scopenet'
|
||||
version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
|
||||
repositories { mavenCentral() }
|
||||
}
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
java.sourceCompatibility = JavaVersion.VERSION_17
|
||||
java.targetCompatibility = JavaVersion.VERSION_17
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
tasks.withType(Test).configureEach { useJUnitPlatform() }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 what the panel queued for this server (a teleport request or message sent from the map, an invitation
|
||||
* notice) every few seconds, and carries it out with the server's normal rules.
|
||||
*/
|
||||
public final class ActionPoller {
|
||||
/** Teleport requests use each platform's own TPA, so they obey its rules and permissions. */
|
||||
public interface Tpa { void request(CorePlayer requester, CorePlayer target); }
|
||||
|
||||
public static final long EVERY_MS = 3_000;
|
||||
|
||||
private final Env env;
|
||||
private final Tpa tpa;
|
||||
private long next;
|
||||
private volatile boolean running;
|
||||
|
||||
public ActionPoller(Env env, Tpa tpa) { this.env = env; this.tpa = tpa; }
|
||||
|
||||
/** Call often (every tick or second) on the server thread; it asks the panel at most every few seconds. */
|
||||
public void tick() {
|
||||
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("actions/poll", new JsonObject());
|
||||
JsonArray actions = answer.isJsonObject() && answer.getAsJsonObject().has("actions") ? answer.getAsJsonObject().getAsJsonArray("actions") : new JsonArray();
|
||||
if (actions.size() > 0) env.platform.runMain(() -> actions.forEach(a -> apply(a.getAsJsonObject())));
|
||||
} 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() : ""; }
|
||||
|
||||
private Optional<CorePlayer> player(String uuid) {
|
||||
try { return env.platform.player(UUID.fromString(uuid)); } catch (IllegalArgumentException e) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
void apply(JsonObject a) {
|
||||
String kind = text(a, "kind");
|
||||
Optional<CorePlayer> to = player(text(a, "to_uuid"));
|
||||
if (to.isEmpty()) return; // left the server since it was queued
|
||||
switch (kind) {
|
||||
case "notify" -> to.get().send(text(a, "text"));
|
||||
case "message" -> to.get().send(Format.GRAY + "[" + Format.AQUA + "Map" + Format.GRAY + "] " + Format.YELLOW + text(a, "from_name") + Format.GRAY + ": " + Format.WHITE + text(a, "text"));
|
||||
case "tpa" -> {
|
||||
Optional<CorePlayer> from = player(text(a, "from_uuid"));
|
||||
if (from.isEmpty()) { to.get().send(Format.GRAY + text(a, "from_name") + " asked to teleport to you, but has left."); return; }
|
||||
if (!from.get().hasPermission("scopenet.command.tpa")) { from.get().send(Format.RED + "You do not have permission to send teleport requests."); return; }
|
||||
tpa.request(from.get(), to.get());
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
import net.scopenet.integration.ChunkCheckResult;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Talks to the optional SCOPENET client mod over the {@link Wire} channels. The client says hello; from then on this
|
||||
* pushes that player's state (level, balance, guild, quests), the claims around them, and answers market and shop
|
||||
* requests. Display only: every action the client takes is an ordinary command, checked like any other.
|
||||
*/
|
||||
public final class ClientLink {
|
||||
private static final int RADIUS = 4;
|
||||
|
||||
private final Env env;
|
||||
private final PlayerCache cache;
|
||||
private final BiConsumer<CorePlayer, byte[]> out;
|
||||
private final String serverName;
|
||||
private final Map<UUID, String> lastState = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, String> lastClaimsKey = new ConcurrentHashMap<>();
|
||||
private final Set<UUID> clients = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, Long> lastRequest = new ConcurrentHashMap<>();
|
||||
/** One request per player at most this often; a modded client can't turn the link into a panel flood. */
|
||||
static final long MIN_REQUEST_GAP_MS = 400;
|
||||
|
||||
public ClientLink(Env env, PlayerCache cache, String serverName, BiConsumer<CorePlayer, byte[]> out) {
|
||||
this.env = env; this.cache = cache; this.serverName = serverName; this.out = out;
|
||||
}
|
||||
|
||||
public boolean hasClient(UUID uuid) { return clients.contains(uuid); }
|
||||
|
||||
public void disconnected(UUID uuid) {
|
||||
clients.remove(uuid); lastState.remove(uuid); lastClaimsKey.remove(uuid); lastRequest.remove(uuid); cache.forget(uuid);
|
||||
}
|
||||
|
||||
private void send(CorePlayer p, JsonObject message) { out.accept(p, Wire.encode(message.toString())); }
|
||||
|
||||
/** A packet from a player's client. Unknown or malformed messages are ignored. */
|
||||
public void receive(CorePlayer p, byte[] payload) {
|
||||
JsonObject msg;
|
||||
try { msg = JsonParser.parseString(Wire.decode(payload)).getAsJsonObject(); }
|
||||
catch (RuntimeException e) { return; }
|
||||
String type = PlayerCache.str(msg, "t", "");
|
||||
if (!env.features.clientLink()) return;
|
||||
long now = env.clock.getAsLong();
|
||||
Long previous = lastRequest.put(p.uuid(), now);
|
||||
if (previous != null && now - previous < MIN_REQUEST_GAP_MS && !type.equals("hello")) return;
|
||||
switch (type) {
|
||||
case "hello" -> hello(p);
|
||||
case "refresh" -> { if (clients.contains(p.uuid())) { lastState.remove(p.uuid()); cache.refresh(p, info -> pushState(p)); } }
|
||||
case "claims" -> { if (clients.contains(p.uuid())) { lastClaimsKey.remove(p.uuid()); pushClaims(p); } }
|
||||
case "market" -> { if (clients.contains(p.uuid())) market(p); }
|
||||
case "shop" -> { if (clients.contains(p.uuid())) shop(p); }
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private void hello(CorePlayer p) {
|
||||
clients.add(p.uuid());
|
||||
Features f = env.features;
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "hello");
|
||||
m.addProperty("v", Wire.VERSION);
|
||||
m.addProperty("server", serverName);
|
||||
m.addProperty("currency", f.currencySymbol());
|
||||
JsonObject features = new JsonObject();
|
||||
features.addProperty("essentials", f.essentials());
|
||||
features.addProperty("economy", f.economy());
|
||||
features.addProperty("guilds", f.guilds());
|
||||
features.addProperty("claims", f.guilds() && f.landClaiming());
|
||||
features.addProperty("social", f.social());
|
||||
m.add("features", features);
|
||||
send(p, m);
|
||||
cache.refresh(p, info -> pushState(p));
|
||||
if (cache.info(p.uuid()) != null) pushState(p);
|
||||
pushClaims(p);
|
||||
}
|
||||
|
||||
/** Push the state if it changed since the last push. */
|
||||
public void pushState(CorePlayer p) {
|
||||
if (!clients.contains(p.uuid())) return;
|
||||
JsonObject i = cache.info(p.uuid());
|
||||
if (i == null) return;
|
||||
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild");
|
||||
JsonObject quests = PlayerCache.obj(i, "quests");
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "state");
|
||||
m.addProperty("name", p.name());
|
||||
m.addProperty("level", (long) PlayerCache.num(global, "level", 1));
|
||||
m.addProperty("xp", (long) PlayerCache.num(global, "xp", 0));
|
||||
m.addProperty("xpNow", (long) PlayerCache.num(global, "current_level_xp", 0));
|
||||
m.addProperty("xpNext", (long) PlayerCache.num(global, "next_level_xp", 0));
|
||||
m.addProperty("xpPct", PlayerCache.num(global, "progress_pct", 0));
|
||||
m.addProperty("title", PlayerCache.str(global, "title", ""));
|
||||
m.addProperty("serverLevel", (long) PlayerCache.num(server, "level", 1));
|
||||
m.addProperty("serverXpPct", PlayerCache.num(server, "progress_pct", 0));
|
||||
if (guild != null) {
|
||||
JsonObject g = new JsonObject();
|
||||
g.addProperty("name", PlayerCache.str(guild, "name", ""));
|
||||
g.addProperty("tag", PlayerCache.str(guild, "tag", ""));
|
||||
g.addProperty("role", PlayerCache.str(guild, "role", ""));
|
||||
g.addProperty("claims", (long) PlayerCache.num(guild, "claims", 0));
|
||||
m.add("guild", g);
|
||||
}
|
||||
m.addProperty("balance", PlayerCache.num(i, "balance", 0));
|
||||
m.addProperty("playtime", (long) PlayerCache.num(i, "playtime_secs", 0));
|
||||
for (String period : List.of("daily", "weekly")) {
|
||||
JsonObject q = PlayerCache.obj(quests, period);
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("done", (long) PlayerCache.num(q, "completed", 0));
|
||||
o.addProperty("total", (long) PlayerCache.num(q, "total", 0));
|
||||
m.add(period, o);
|
||||
}
|
||||
JsonObject rank = PlayerCache.obj(i, "rank");
|
||||
if (rank != null) m.addProperty("rank", PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")));
|
||||
m.addProperty("kills", (long) PlayerCache.num(i, "kills", 0));
|
||||
m.addProperty("deaths", (long) PlayerCache.num(i, "deaths", 0));
|
||||
String text = m.toString();
|
||||
if (text.equals(lastState.put(p.uuid(), text))) return;
|
||||
send(p, m);
|
||||
}
|
||||
|
||||
/** Claims around the player; sent when they change chunk or the claim map changes. */
|
||||
public void pushClaims(CorePlayer p) {
|
||||
if (!clients.contains(p.uuid()) || !env.features.guilds() || !env.features.landClaiming()) return;
|
||||
Pos pos = p.pos();
|
||||
int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4;
|
||||
StringBuilder cells = new StringBuilder();
|
||||
for (int dz = -RADIUS; dz <= RADIUS; dz++) {
|
||||
for (int dx = -RADIUS; dx <= RADIUS; dx++) {
|
||||
ChunkCheckResult r = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid());
|
||||
cells.append(!r.claimed() ? '0' : r.allowed() ? '1' : '2');
|
||||
}
|
||||
}
|
||||
ChunkCheckResult here = env.panel.check(pos.world(), cx, cz, p.uuid());
|
||||
String key = pos.world() + ":" + cx + ":" + cz + ":" + cells;
|
||||
if (key.equals(lastClaimsKey.put(p.uuid(), key))) return;
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "claims");
|
||||
m.addProperty("dim", pos.world());
|
||||
m.addProperty("cx", cx);
|
||||
m.addProperty("cz", cz);
|
||||
m.addProperty("r", RADIUS);
|
||||
m.addProperty("cells", cells.toString());
|
||||
if (here.claimed()) {
|
||||
JsonObject owner = new JsonObject();
|
||||
owner.addProperty("name", here.guildName() == null ? "" : here.guildName());
|
||||
owner.addProperty("tag", here.guildTag() == null ? "" : here.guildTag());
|
||||
owner.addProperty("mine", here.allowed());
|
||||
m.add("owner", owner);
|
||||
}
|
||||
send(p, m);
|
||||
}
|
||||
|
||||
private void market(CorePlayer p) {
|
||||
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "market");
|
||||
m.add("listings", el.isJsonArray() ? el.getAsJsonArray() : new JsonArray());
|
||||
m.addProperty("balance", PlayerCache.num(cache.info(p.uuid()), "balance", 0));
|
||||
send(p, m);
|
||||
}, e -> notify(p, "error", "Market", "Could not load listings: " + e));
|
||||
}
|
||||
|
||||
private void shop(CorePlayer p) {
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "shop");
|
||||
JsonArray items = new JsonArray();
|
||||
ItemValues.all().forEach((id, price) -> {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", id);
|
||||
o.addProperty("name", id.toLowerCase(Locale.ROOT).replace('_', ' '));
|
||||
o.addProperty("price", price);
|
||||
items.add(o);
|
||||
});
|
||||
m.add("items", items);
|
||||
p.heldItem().ifPresent(h -> {
|
||||
JsonObject held = new JsonObject();
|
||||
held.addProperty("id", h.id());
|
||||
held.addProperty("name", h.name());
|
||||
held.addProperty("count", h.count());
|
||||
Double unit = ItemValues.unitPrice(h.id());
|
||||
if (unit != null) held.addProperty("unit", unit);
|
||||
m.add("held", held);
|
||||
});
|
||||
send(p, m);
|
||||
}
|
||||
|
||||
/** Ask the client mod to open one of its windows ("market" or "shop"). Returns false if the player has no client mod. */
|
||||
public boolean open(CorePlayer p, String screen) {
|
||||
if (!clients.contains(p.uuid())) return false;
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "open");
|
||||
m.addProperty("screen", screen);
|
||||
send(p, m);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A toast for the client (level-up, achievement, friend request…). */
|
||||
public void notify(CorePlayer p, String kind, String title, String text) {
|
||||
if (!clients.contains(p.uuid())) return;
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "notify");
|
||||
m.addProperty("kind", kind);
|
||||
m.addProperty("title", title);
|
||||
m.addProperty("text", text);
|
||||
send(p, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Events the panel queued for this server (level-ups, achievements, guild changes). Safe to call from any thread:
|
||||
* each becomes a toast on the player's client and forces a fresh state.
|
||||
*/
|
||||
public void onPanelEvents(JsonArray events) {
|
||||
for (JsonElement element : events) {
|
||||
if (!element.isJsonObject()) continue;
|
||||
JsonObject n = element.getAsJsonObject();
|
||||
String kind = PlayerCache.str(n, "kind", "");
|
||||
String uuidText = PlayerCache.str(n, "uuid", "");
|
||||
JsonObject d = PlayerCache.obj(n, "data");
|
||||
UUID uuid;
|
||||
try { uuid = UUID.fromString(uuidText); } catch (IllegalArgumentException e) { continue; }
|
||||
String title, text;
|
||||
switch (kind) {
|
||||
case "level_up" -> { title = "Level up!"; text = ("server".equals(PlayerCache.str(d, "scope", "")) ? "Server level " : "Level ") + (long) PlayerCache.num(d, "level", 0); }
|
||||
case "achievement" -> { title = "Achievement unlocked"; text = PlayerCache.str(d, "title", ""); }
|
||||
case "guild_join" -> { title = "Guild"; text = "Joined " + PlayerCache.str(d, "guild", "a guild"); }
|
||||
case "guild_leave" -> { title = "Guild"; text = "Left " + PlayerCache.str(d, "guild", "your guild"); }
|
||||
default -> { continue; }
|
||||
}
|
||||
final String t = title, x = text, k = kind;
|
||||
env.platform.runMain(() -> env.platform.player(uuid).ifPresent(p -> {
|
||||
notify(p, k, t, x);
|
||||
lastState.remove(uuid);
|
||||
cache.refresh(p, info -> pushState(p));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** About once a second: new chunk, new claims, or new state for every client. */
|
||||
public void tick() {
|
||||
for (UUID id : new ArrayList<>(clients)) {
|
||||
Optional<CorePlayer> p = env.platform.player(id);
|
||||
if (p.isEmpty()) { disconnected(id); continue; }
|
||||
pushClaims(p.get());
|
||||
pushState(p.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/** A {@link CoreCommand} built from lambdas. */
|
||||
final class Cmd implements CoreCommand {
|
||||
private final String name;
|
||||
private final List<String> aliases;
|
||||
private final String permission;
|
||||
private final BiConsumer<CorePlayer, String[]> run;
|
||||
private final BiFunction<CorePlayer, String[], List<String>> complete;
|
||||
|
||||
Cmd(String name, List<String> aliases, String permission, BiConsumer<CorePlayer, String[]> run,
|
||||
BiFunction<CorePlayer, String[], List<String>> complete) {
|
||||
this.name = name; this.aliases = aliases; this.permission = permission; this.run = run; this.complete = complete;
|
||||
}
|
||||
|
||||
static Cmd of(String name, List<String> aliases, BiConsumer<CorePlayer, String[]> run) {
|
||||
return new Cmd(name, aliases, "scopenet.command." + name, run, (p, a) -> List.of());
|
||||
}
|
||||
|
||||
Cmd completing(BiFunction<CorePlayer, String[], List<String>> c) { return new Cmd(name, aliases, permission, run, c); }
|
||||
|
||||
@Override public String name() { return name; }
|
||||
@Override public List<String> aliases() { return aliases; }
|
||||
@Override public String permission() { return permission; }
|
||||
@Override public void run(CorePlayer player, String[] args) { run.accept(player, args); }
|
||||
@Override public List<String> complete(CorePlayer player, String[] args) { return complete.apply(player, args); }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Builds every shared command, wired to its services, and gates them on feature switches and permission nodes. */
|
||||
public final class CommandSet {
|
||||
public final EssentialsService essentials;
|
||||
public final Jobs jobs;
|
||||
public final ShopPoints shops;
|
||||
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;
|
||||
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
|
||||
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
|
||||
shops = new ShopPoints(dir.resolve("shops.json"));
|
||||
EconomyCommands economy = new EconomyCommands(env, jobs, shops);
|
||||
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
|
||||
economyCommands = economy;
|
||||
guildCommands = guilds;
|
||||
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());
|
||||
}
|
||||
|
||||
private void add(Env env, List<CoreCommand> list, String system, java.util.function.BooleanSupplier enabled) {
|
||||
for (CoreCommand c : list) commands.add(new Gated(c, system, enabled));
|
||||
}
|
||||
|
||||
public List<CoreCommand> all() { return List.copyOf(commands); }
|
||||
|
||||
/** Run a command by name as this player, with the same checks as typing it. */
|
||||
public void run(String name, CorePlayer player, String... args) {
|
||||
for (CoreCommand c : commands) if (c.name().equals(name)) { c.run(player, args); return; }
|
||||
throw new IllegalArgumentException("no command " + name);
|
||||
}
|
||||
|
||||
/** What this command set knows that the map can show. */
|
||||
public net.scopenet.core.map.MapSource mapSource() {
|
||||
return new net.scopenet.core.map.MapSource() {
|
||||
@Override public java.util.Map<String, Pos> warps() { return essentials.allWarps(); }
|
||||
@Override public java.util.Map<java.util.UUID, java.util.Map<String, Pos>> homes() { return essentials.allHomes(); }
|
||||
@Override public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
|
||||
@Override public java.util.Collection<ShopPoints.Shop> shops() { return shops.all(); }
|
||||
};
|
||||
}
|
||||
|
||||
/** Guild homes by guild id (for the map). */
|
||||
public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
|
||||
|
||||
/** 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(); }
|
||||
@Override public List<String> aliases() { return inner.aliases(); }
|
||||
@Override public String permission() { return inner.permission(); }
|
||||
@Override public void run(CorePlayer p, String[] args) {
|
||||
if (!enabled.getAsBoolean()) { p.send(Format.RED + system + " are disabled."); return; }
|
||||
if (!p.hasPermission(inner.permission())) { p.send(Format.RED + "You do not have permission to use /" + inner.name() + "."); return; }
|
||||
inner.run(p, args);
|
||||
}
|
||||
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One slash command, the same on every platform. */
|
||||
public interface CoreCommand {
|
||||
String name();
|
||||
default List<String> aliases() { return List.of(); }
|
||||
/** Node checked before {@link #run}; platforms apply LuckPerms or their default. */
|
||||
String permission();
|
||||
void run(CorePlayer player, String[] args);
|
||||
/** Tab-completion candidates for the last argument. */
|
||||
default List<String> complete(CorePlayer player, String[] args) { return List.of(); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** What the shared logic needs from an online player. Each platform provides an adapter. */
|
||||
public interface CorePlayer {
|
||||
UUID uuid();
|
||||
String name();
|
||||
Pos pos();
|
||||
/** Send a chat message. Legacy section-sign colour codes are allowed. Safe to call from the server thread. */
|
||||
void send(String message);
|
||||
void teleport(Pos destination);
|
||||
/** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */
|
||||
boolean hasPermission(String node);
|
||||
/** Total time played, in seconds. */
|
||||
long playtimeSeconds();
|
||||
/** The stack in the main hand, empty if none. */
|
||||
Optional<Item> heldItem();
|
||||
/** Remove whatever is in the main hand. */
|
||||
void clearHeld();
|
||||
/** Put the stack in the inventory, or drop it at the player's feet if full. */
|
||||
void give(Item item);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* /balance /pay /baltop /shop /sell /market /orders /transactions, in chat. Money lives in the panel; selling and buying
|
||||
* go through {@link Jobs} so a crash or outage never loses an item or a payment. Shop, sell-chest and market GUIs
|
||||
* are the client mod's job; the commands here are what it (and plain chat) use.
|
||||
*/
|
||||
final class EconomyCommands {
|
||||
private final Env env;
|
||||
private final Jobs jobs;
|
||||
private final ShopPoints shops;
|
||||
|
||||
EconomyCommands(Env env, Jobs jobs, ShopPoints shops) { this.env = env; this.jobs = jobs; this.shops = shops; }
|
||||
|
||||
List<CoreCommand> build() {
|
||||
List<CoreCommand> out = new ArrayList<>();
|
||||
out.add(Cmd.of("balance", List.of("bal", "money"), (p, a) -> balance(p)));
|
||||
out.add(Cmd.of("pay", List.of(), this::pay).completing((p, a) -> a.length <= 1 ? EssentialsCommands.names(env, p) : List.of()));
|
||||
out.add(Cmd.of("baltop", List.of("richest"), (p, a) -> baltop(p)));
|
||||
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(p)));
|
||||
out.add(Cmd.of("sell", List.of(), (p, a) -> sell(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("hand") : List.of()));
|
||||
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy", "point") : a.length == 2 && a[0].equalsIgnoreCase("point") ? List.of("add", "remove", "list") : List.of()));
|
||||
out.add(Cmd.of("orders", List.of(), (p, a) -> orders(p)));
|
||||
out.add(Cmd.of("trade", List.of(), (p, a) -> p.send(Format.RED + "/trade needs the chest window, which isn't available on this platform yet. Use /market or /pay.")));
|
||||
out.add(Cmd.of("transactions", List.of(), (p, a) -> transactions(p)));
|
||||
return out;
|
||||
}
|
||||
|
||||
private JsonObject who(CorePlayer p) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("uuid", p.uuid().toString());
|
||||
o.addProperty("username", p.name());
|
||||
return o;
|
||||
}
|
||||
|
||||
void balance(CorePlayer p) {
|
||||
p.send(Format.GRAY + "Fetching your balance...");
|
||||
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
|
||||
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
|
||||
p.send(Format.GOLD + "=== SCOPENET Economy ===");
|
||||
p.send(Format.YELLOW + "Your Balance: " + Format.GREEN + env.money(bal));
|
||||
}, e -> p.send(Format.RED + "Could not reach economy server: " + e));
|
||||
}
|
||||
|
||||
void pay(CorePlayer p, String[] args) {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /pay <player> <amount>"); return; }
|
||||
Optional<CorePlayer> found = env.platform.playerByName(args[0]);
|
||||
if (found.isEmpty()) { p.send(Format.RED + "Player '" + args[0] + "' is not online."); return; }
|
||||
CorePlayer target = found.get();
|
||||
if (target.uuid().equals(p.uuid())) { p.send(Format.RED + "You cannot pay yourself."); return; }
|
||||
Double amount;
|
||||
try { amount = Double.parseDouble(args[1]); } catch (NumberFormatException e) { amount = null; }
|
||||
if (amount == null || amount <= 0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
|
||||
p.send(Format.RED + "Invalid amount specified. Must be positive number.");
|
||||
return;
|
||||
}
|
||||
final double value = amount;
|
||||
p.send(Format.GRAY + "Processing payment...");
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("from_uuid", p.uuid().toString());
|
||||
body.addProperty("from_name", p.name());
|
||||
body.addProperty("to_uuid", target.uuid().toString());
|
||||
body.addProperty("to_name", target.name());
|
||||
body.addProperty("amount", value);
|
||||
body.addProperty("description", "Player payment to " + target.name());
|
||||
env.io(() -> env.panel.call("economy/transfer", body).getAsJsonObject(), r -> {
|
||||
double left = r.has("from_balance") ? r.get("from_balance").getAsDouble() : 0.0;
|
||||
p.send(Format.GREEN + "Sent " + Format.YELLOW + env.money(value) + Format.GREEN + " to " + Format.YELLOW + target.name()
|
||||
+ Format.GREEN + ". New Balance: " + Format.YELLOW + env.money(left));
|
||||
target.send(Format.GREEN + "Received " + Format.YELLOW + env.money(value) + Format.GREEN + " from " + Format.YELLOW + p.name() + Format.GREEN + "!");
|
||||
}, e -> p.send(Format.RED + "Transfer failed: " + e));
|
||||
}
|
||||
|
||||
void baltop(CorePlayer p) {
|
||||
p.send(Format.GRAY + "Loading leaderboard...");
|
||||
env.io(() -> env.panel.call("economy/baltop", new JsonObject()), el -> {
|
||||
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
p.send(Format.GOLD + "================= " + Format.YELLOW + "BALTOP (Richest Players)" + Format.GOLD + " =================");
|
||||
if (list.size() == 0) { p.send(Format.GRAY + "No players found on the leaderboard."); return; }
|
||||
for (int i = 0; i < Math.min(10, list.size()); i++) {
|
||||
JsonObject e = list.get(i).getAsJsonObject();
|
||||
int rank = e.has("rank") ? e.get("rank").getAsInt() : i + 1;
|
||||
String name = e.has("username") ? e.get("username").getAsString() : "Unknown";
|
||||
double bal = e.has("balance") ? e.get("balance").getAsDouble() : 0.0;
|
||||
String colour = rank == 1 ? Format.GOLD : rank == 2 ? Format.WHITE : rank == 3 ? Format.YELLOW : Format.GRAY;
|
||||
p.send(colour + "#" + rank + " " + Format.WHITE + name + ": " + Format.GREEN + env.money(bal));
|
||||
}
|
||||
}, e -> p.send(Format.RED + "Could not fetch baltop: " + e));
|
||||
}
|
||||
|
||||
void shop(CorePlayer p) {
|
||||
p.send(Format.GOLD + "=== Server Shop (sell prices) ===");
|
||||
ItemValues.all().forEach((id, price) -> p.send(Format.GRAY + " " + id.toLowerCase(Locale.ROOT).replace('_', ' ') + Format.DARK_GRAY + " - " + Format.GREEN + env.money(price)));
|
||||
p.send(Format.GRAY + "Hold items and use " + Format.YELLOW + "/sell hand" + Format.GRAY + " to sell them.");
|
||||
}
|
||||
|
||||
/** {@code guild}: the proceeds go to the player's guild bank. */
|
||||
void sell(CorePlayer p, String[] args, boolean guild) {
|
||||
if (args.length == 0 || !args[0].equalsIgnoreCase("hand")) {
|
||||
p.send(Format.YELLOW + "Hold the items you want to sell and use /" + (guild ? "guild sell" : "sell") + " hand." + Format.GRAY + " See prices with /shop.");
|
||||
return;
|
||||
}
|
||||
if (!guild && !p.hasPermission("scopenet.command.sell.hand")) { p.send(Format.RED + "You do not have permission to use /sell hand."); return; }
|
||||
Optional<Item> held = p.heldItem();
|
||||
if (held.isEmpty()) { p.send(Format.RED + "You are not holding any item to sell."); return; }
|
||||
Item item = held.get();
|
||||
Double unit = ItemValues.unitPrice(item.id());
|
||||
if (unit == null) { p.send(Format.RED + "This item (" + item.id() + ") cannot be sold to the server shop."); return; }
|
||||
JsonObject payload = who(p);
|
||||
payload.addProperty(guild ? "amount" : "delta", unit * item.count());
|
||||
payload.addProperty("description", "Sell hand: " + item.id());
|
||||
p.clearHeld();
|
||||
if (!jobs.enqueue(p, guild ? "guilds/bank/credit" : "economy/adjust", payload, List.of(item), null)) p.give(item);
|
||||
}
|
||||
|
||||
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */
|
||||
void market(CorePlayer p, String[] args, boolean asGuild) {
|
||||
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
|
||||
// The block under the player's feet: the platform-neutral stand-in for "the block you look at".
|
||||
Pos at = p.pos();
|
||||
shops.command(p, Arrays.copyOfRange(args, 1, args.length), new Pos(at.world(), at.x(), at.y() - 1, at.z(), 0, 0));
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
|
||||
if (!asGuild && !p.hasPermission("scopenet.command.market.buy")) { p.send(Format.RED + "You do not have permission to buy from the market."); return; }
|
||||
long listing;
|
||||
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
|
||||
p.send(Format.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as #n in /market)");
|
||||
return;
|
||||
}
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("listing_id", listing);
|
||||
payload.addProperty("buyer_uuid", p.uuid().toString());
|
||||
payload.addProperty("buyer_name", p.name());
|
||||
if (asGuild) payload.addProperty("as_guild", true);
|
||||
jobs.enqueue(p, "economy/market/buy", payload, List.of(), null);
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
|
||||
if (!asGuild && !p.hasPermission("scopenet.command.market.sell")) { p.send(Format.RED + "You do not have permission to list items on the market."); return; }
|
||||
Optional<Item> held = p.heldItem();
|
||||
if (held.isEmpty()) { p.send(Format.RED + "Hold the item you want to sell in your main hand."); return; }
|
||||
Double price;
|
||||
try { price = Double.parseDouble(args[1]); } catch (NumberFormatException e) { price = null; }
|
||||
if (price == null || price <= 0 || price.isNaN() || price.isInfinite()) { p.send(Format.RED + "Invalid price. Usage: /market sell <price>"); return; }
|
||||
Item item = held.get();
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("seller_uuid", p.uuid().toString());
|
||||
payload.addProperty("seller_name", p.name());
|
||||
payload.addProperty("item_id", item.id());
|
||||
payload.addProperty("item_name", item.name());
|
||||
payload.addProperty("amount", item.count());
|
||||
payload.addProperty("price", price);
|
||||
payload.addProperty("item_data", item.data());
|
||||
if (asGuild) payload.addProperty("as_guild", true);
|
||||
p.clearHeld(); // escrow first, then a retryable operation
|
||||
if (!jobs.enqueue(p, "economy/market/list", payload, List.of(item), null)) p.give(item);
|
||||
return;
|
||||
}
|
||||
listMarket(p);
|
||||
}
|
||||
|
||||
void listMarket(CorePlayer p) {
|
||||
p.send(Format.GRAY + "Loading market listings...");
|
||||
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
|
||||
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
p.send(Format.GOLD + "=== Player Market ===");
|
||||
if (list.size() == 0) { p.send(Format.GRAY + "No listings. Be the first: hold an item and use /market sell <price>."); return; }
|
||||
for (JsonElement e : list) {
|
||||
JsonObject l = e.getAsJsonObject();
|
||||
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? l.get("seller_guild").getAsString() : null;
|
||||
String seller = guild != null ? Format.GOLD + "[" + guild + "] guild" : Format.WHITE + l.get("seller_name").getAsString();
|
||||
p.send(Format.DARK_GRAY + "#" + l.get("id").getAsLong() + " " + Format.WHITE + l.get("item_name").getAsString() + Format.GRAY + " x" + l.get("amount").getAsInt()
|
||||
+ " " + Format.GREEN + env.money(l.get("price").getAsDouble()) + Format.GRAY + " by " + seller);
|
||||
}
|
||||
p.send(Format.GRAY + "Buy with " + Format.YELLOW + "/market buy <#id>");
|
||||
}, e -> p.send(Format.RED + "Could not load market listings: " + e));
|
||||
}
|
||||
|
||||
void orders(CorePlayer p) {
|
||||
p.send(Format.GOLD + "=== Your Active Market Orders ===");
|
||||
p.send(Format.GRAY + "Use " + Format.YELLOW + "/market sell <price>" + Format.GRAY + " to list items, or browse with " + Format.YELLOW + "/market" + Format.GRAY + ".");
|
||||
}
|
||||
|
||||
void transactions(CorePlayer p) {
|
||||
p.send(Format.GOLD + "=== Recent Transactions ===");
|
||||
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
|
||||
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
|
||||
p.send(Format.GREEN + "Current Balance: " + Format.YELLOW + env.money(bal));
|
||||
p.send(Format.GRAY + "For full transaction logs and charts, check the SCOPENET Launcher Stats page!");
|
||||
}, e -> p.send(Format.RED + "Error: " + e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Everything a command needs: the platform, the panel, settings and a way to do network work safely. */
|
||||
public final class Env {
|
||||
public final Platform platform;
|
||||
public final Panel panel;
|
||||
public volatile Features features;
|
||||
public final Path dataDir;
|
||||
public final LongSupplier clock;
|
||||
public final Random rng;
|
||||
public final Logger log;
|
||||
|
||||
public Env(Platform platform, Panel panel, Features features, Path dataDir, LongSupplier clock, Random rng, Logger log) {
|
||||
this.platform = platform; this.panel = panel; this.features = features; this.dataDir = dataDir;
|
||||
this.clock = clock; this.rng = rng; this.log = log;
|
||||
}
|
||||
|
||||
/** Do {@code work} off the server thread, then call back on it with the result or an error message. */
|
||||
public <T> void io(Callable<T> work, Consumer<T> ok, Consumer<String> fail) {
|
||||
platform.runAsync(() -> {
|
||||
try {
|
||||
T result = work.call();
|
||||
platform.runMain(() -> ok.accept(result));
|
||||
} catch (Exception e) {
|
||||
String message = e.getMessage() != null && !e.getMessage().isBlank() ? e.getMessage() : e.getClass().getSimpleName();
|
||||
platform.runMain(() -> fail.accept(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public String money(double value) { return Format.money(features.currencySymbol(), value); }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime. */
|
||||
final class EssentialsCommands {
|
||||
private EssentialsCommands() {}
|
||||
|
||||
static List<CoreCommand> build(Env env, EssentialsService ess) {
|
||||
List<CoreCommand> out = new ArrayList<>();
|
||||
out.add(Cmd.of("spawn", List.of("hub", "lobby"), (p, a) -> {
|
||||
Pos spawn = env.platform.worldSpawn(p.pos().world());
|
||||
ess.teleportTo(p, new Pos(spawn.world(), spawn.x() + 0.5, spawn.y(), spawn.z() + 0.5, spawn.yaw(), spawn.pitch()));
|
||||
p.send(Format.GREEN + "Teleported to spawn.");
|
||||
}));
|
||||
out.add(Cmd.of("home", List.of("homes"), (p, a) -> {
|
||||
List<String> choices = ess.home(p, a);
|
||||
if (!choices.isEmpty()) p.send(Format.GOLD + "Your homes: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
|
||||
+ Format.GRAY + " (use /home <name>)");
|
||||
}).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
|
||||
out.add(Cmd.of("sethome", List.of("createshome"), ess::setHome));
|
||||
out.add(Cmd.of("delhome", List.of("rmhome"), ess::delHome).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
|
||||
out.add(Cmd.of("back", List.of("return"), (p, a) -> ess.back(p)));
|
||||
out.add(Cmd.of("tpa", List.of(), ess::tpa).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
|
||||
out.add(Cmd.of("tpaccept", List.of("tpyes"), (p, a) -> ess.tpAccept(p)));
|
||||
out.add(Cmd.of("tpdeny", List.of("tpno"), (p, a) -> ess.tpDeny(p)));
|
||||
out.add(Cmd.of("rtp", List.of("wild", "randomtp"), (p, a) -> ess.rtp(p)));
|
||||
out.add(Cmd.of("warp", List.of("warps"), (p, a) -> {
|
||||
List<String> choices = ess.warp(p, a);
|
||||
if (!choices.isEmpty()) p.send(Format.AQUA + "Warps: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
|
||||
+ Format.GRAY + " (use /warp <name>)");
|
||||
}).completing((p, a) -> a.length <= 1 ? ess.warpNames() : List.of()));
|
||||
out.add(Cmd.of("playtime", List.of("ontime"), (p, a) -> {
|
||||
CorePlayer target = p;
|
||||
if (a.length > 0) {
|
||||
Optional<CorePlayer> found = env.platform.playerByName(a[0]);
|
||||
if (found.isEmpty()) { p.send(Format.RED + "Player not found."); return; }
|
||||
target = found.get();
|
||||
}
|
||||
p.send(Format.GOLD + "=== Playtime: " + Format.YELLOW + target.name() + Format.GOLD + " ===");
|
||||
p.send(Format.GRAY + "Total Time: " + Format.GREEN + Format.duration(target.playtimeSeconds()));
|
||||
}).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<String> names(Env env, CorePlayer except) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (CorePlayer p : env.platform.online()) if (!p.uuid().equals(except.uuid())) names.add(p.name());
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
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); }
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Homes, warps, /back, TPA and RTP, the same on every platform. Behaviour and wording match the Paper
|
||||
* plugin. Call from the server thread; state is saved to one JSON file after every change.
|
||||
*/
|
||||
public final class EssentialsService {
|
||||
private static final String GREEN = "§a", RED = "§c", YELLOW = "§e", GRAY = "§7";
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private record Data(Map<String, Map<String, Pos>> homes, Map<String, Pos> warps) {}
|
||||
|
||||
private final Platform platform;
|
||||
private final Path file;
|
||||
private final LongSupplier clock;
|
||||
private final Random rng;
|
||||
private volatile EssentialsConfig config;
|
||||
|
||||
private final Map<UUID, Map<String, Pos>> homes = new ConcurrentHashMap<>();
|
||||
private final Map<String, Pos> warps = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Pos> lastLocations = new ConcurrentHashMap<>();
|
||||
/** target -> (requester, sent at) */
|
||||
private final Map<UUID, Map.Entry<UUID, Long>> pendingTpa = new ConcurrentHashMap<>();
|
||||
|
||||
public EssentialsService(Platform platform, Path file, EssentialsConfig config, LongSupplier clock, Random rng) {
|
||||
this.platform = platform;
|
||||
this.file = file;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
this.rng = rng;
|
||||
load();
|
||||
}
|
||||
|
||||
public void configure(EssentialsConfig next) { this.config = next; }
|
||||
|
||||
public int cooldownSeconds(String command) { return config.cooldown(command); }
|
||||
|
||||
// ---- persistence -------------------------------------------------------
|
||||
|
||||
private void load() {
|
||||
if (!Files.exists(file)) return;
|
||||
try (Reader in = Files.newBufferedReader(file)) {
|
||||
Data data = GSON.fromJson(in, new TypeToken<Data>() {}.getType());
|
||||
if (data == null) return;
|
||||
if (data.homes() != null) data.homes().forEach((id, h) -> {
|
||||
try { homes.put(UUID.fromString(id), new HashMap<>(h)); } catch (IllegalArgumentException ignored) { }
|
||||
});
|
||||
if (data.warps() != null) warps.putAll(data.warps());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void save() {
|
||||
Map<String, Map<String, Pos>> out = new TreeMap<>();
|
||||
homes.forEach((id, h) -> { if (!h.isEmpty()) out.put(id.toString(), new TreeMap<>(h)); });
|
||||
try {
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
if (file.getParent() != null) Files.createDirectories(file.getParent());
|
||||
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(new Data(out, new TreeMap<>(warps)), w); }
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- location memory for /back ----------------------------------------
|
||||
|
||||
/** Platforms call this before a teleport and on death. */
|
||||
public void remember(UUID player, Pos where) { lastLocations.put(player, where); }
|
||||
|
||||
// ---- homes -------------------------------------------------------------
|
||||
|
||||
public List<String> homeNames(UUID player) {
|
||||
return new ArrayList<>(new TreeSet<>(homes.getOrDefault(player, Map.of()).keySet()));
|
||||
}
|
||||
|
||||
public Optional<Pos> homeAt(UUID player, String name) {
|
||||
return Optional.ofNullable(homes.getOrDefault(player, Map.of()).get(name.toLowerCase(Locale.ROOT)));
|
||||
}
|
||||
|
||||
/** {@code /home [name]}. Returns the homes to choose from when several exist and no name was given. */
|
||||
public List<String> home(CorePlayer p, String[] args) {
|
||||
Map<String, Pos> mine = homes.getOrDefault(p.uuid(), Map.of());
|
||||
if (args.length == 0) {
|
||||
if (mine.isEmpty()) { p.send(RED + "You have no homes set! Set one with " + YELLOW + "/sethome [name]"); return List.of(); }
|
||||
if (mine.size() == 1) { go(p, mine.values().iterator().next()); p.send(GREEN + "Teleported to home."); return List.of(); }
|
||||
return homeNames(p.uuid());
|
||||
}
|
||||
String name = args[0].toLowerCase(Locale.ROOT);
|
||||
Pos pos = mine.get(name);
|
||||
if (pos == null) {
|
||||
p.send(RED + "Home '" + name + "' not found. Your homes: " + YELLOW + String.join(", ", homeNames(p.uuid())));
|
||||
return List.of();
|
||||
}
|
||||
go(p, pos);
|
||||
p.send(GREEN + "Teleported to home " + YELLOW + name + GREEN + ".");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
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 (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());
|
||||
save();
|
||||
p.send(GREEN + "Home " + YELLOW + name + GREEN + " set successfully.");
|
||||
}
|
||||
|
||||
public void delHome(CorePlayer p, String[] args) {
|
||||
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
|
||||
Map<String, Pos> mine = homes.get(p.uuid());
|
||||
if (mine == null || mine.remove(name) == null) { p.send(RED + "Home '" + name + "' does not exist."); return; }
|
||||
save();
|
||||
p.send(GREEN + "Home " + YELLOW + name + GREEN + " deleted.");
|
||||
}
|
||||
|
||||
// ---- warps -------------------------------------------------------------
|
||||
|
||||
/** Every warp, for the map. A copy. */
|
||||
public Map<String, Pos> allWarps() { return new TreeMap<>(warps); }
|
||||
|
||||
/** Every player's homes, for the map (only shown when the admin turns that on). A copy. */
|
||||
public Map<UUID, Map<String, Pos>> allHomes() {
|
||||
Map<UUID, Map<String, Pos>> out = new HashMap<>();
|
||||
homes.forEach((id, h) -> out.put(id, new TreeMap<>(h)));
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); }
|
||||
|
||||
public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); }
|
||||
|
||||
/** {@code /warp [name]}. With no name, returns the list for a picker (seeding "spawn" if empty). */
|
||||
public List<String> warp(CorePlayer p, String[] args) {
|
||||
if (args.length == 0) {
|
||||
if (warps.isEmpty()) { warps.put("spawn", platform.worldSpawn(p.pos().world())); save(); }
|
||||
return warpNames();
|
||||
}
|
||||
String name = args[0].toLowerCase(Locale.ROOT);
|
||||
Pos pos = warps.get(name);
|
||||
if (pos == null) { p.send(RED + "Warp '" + name + "' does not exist."); return List.of(); }
|
||||
go(p, pos);
|
||||
p.send(GREEN + "Teleported to warp " + YELLOW + name + GREEN + ".");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
public void delWarp(CorePlayer p, String name) {
|
||||
if (warps.remove(name.toLowerCase(Locale.ROOT)) == null) { p.send(RED + "Warp '" + name + "' does not exist."); return; }
|
||||
save();
|
||||
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " deleted.");
|
||||
}
|
||||
|
||||
// ---- back --------------------------------------------------------------
|
||||
|
||||
public void back(CorePlayer p) {
|
||||
Pos last = lastLocations.get(p.uuid());
|
||||
if (last == null) { p.send(RED + "No previous location found to return to."); return; }
|
||||
go(p, last);
|
||||
p.send(GREEN + "Teleported back to your previous location.");
|
||||
}
|
||||
|
||||
// ---- tpa ---------------------------------------------------------------
|
||||
|
||||
public void tpa(CorePlayer p, String[] args) {
|
||||
if (args.length < 1) { p.send(RED + "Usage: /tpa <player>"); return; }
|
||||
Optional<CorePlayer> found = platform.playerByName(args[0]);
|
||||
if (found.isEmpty()) { p.send(RED + "Player '" + args[0] + "' is not online."); return; }
|
||||
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
|
||||
+ " to deny (expires in " + config.tpaTimeoutMs() / 1000 + "s).");
|
||||
}
|
||||
|
||||
public void tpAccept(CorePlayer p) {
|
||||
Map.Entry<UUID, Long> req = pendingTpa.get(p.uuid());
|
||||
if (req == null || clock.getAsLong() - req.getValue() > config.tpaTimeoutMs()) {
|
||||
pendingTpa.remove(p.uuid());
|
||||
p.send(RED + "You have no active teleport requests.");
|
||||
return;
|
||||
}
|
||||
pendingTpa.remove(p.uuid());
|
||||
Optional<CorePlayer> requester = platform.player(req.getKey());
|
||||
if (requester.isEmpty()) { p.send(RED + "The player who requested to teleport is no longer online."); return; }
|
||||
go(requester.get(), p.pos());
|
||||
requester.get().send(GREEN + "Teleport request accepted by " + YELLOW + p.name() + GREEN + "!");
|
||||
p.send(GREEN + "Accepted teleport request from " + YELLOW + requester.get().name() + GREEN + ".");
|
||||
}
|
||||
|
||||
public void tpDeny(CorePlayer p) {
|
||||
Map.Entry<UUID, Long> req = pendingTpa.remove(p.uuid());
|
||||
if (req == null) { p.send(RED + "You have no active teleport requests."); return; }
|
||||
platform.player(req.getKey()).ifPresent(r -> r.send(RED + p.name() + " denied your teleport request."));
|
||||
p.send(YELLOW + "Teleport request denied.");
|
||||
}
|
||||
|
||||
// ---- rtp ---------------------------------------------------------------
|
||||
|
||||
/** 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();
|
||||
go(p, new Pos(world, x + 0.5, at.y(), z + 0.5, p.pos().yaw(), p.pos().pitch()));
|
||||
p.send(GREEN + "Wilderness RTP: Teleported to " + YELLOW + x + ", " + at.blockY() + ", " + z + GREEN + "!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
p.send(RED + "Could not find a safe RTP spot. Please try again.");
|
||||
}
|
||||
|
||||
/** 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,7 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
/** Which systems are switched on, plus small display settings. */
|
||||
public record Features(boolean essentials, boolean economy, boolean guilds, boolean social, boolean landClaiming,
|
||||
boolean clientLink, String currencySymbol) {
|
||||
public static Features all() { return new Features(true, true, true, true, true, true, "$"); }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Text helpers shared by every command. Colour codes are legacy section signs. */
|
||||
public final class Format {
|
||||
public static final String GOLD = "§6", YELLOW = "§e", GREEN = "§a", RED = "§c", GRAY = "§7",
|
||||
WHITE = "§f", AQUA = "§b", DARK_GRAY = "§8";
|
||||
private Format() {}
|
||||
|
||||
public static String money(String symbol, double value) { return symbol + String.format(Locale.US, "%,.2f", value); }
|
||||
|
||||
/** Positive amount with at most two decimals, or null. */
|
||||
public static Double amount(String text) {
|
||||
try {
|
||||
double value = Double.parseDouble(text.replace(",", ""));
|
||||
if (Double.isNaN(value) || Double.isInfinite(value) || value < 0.01) return null;
|
||||
return Math.round(value * 100.0) / 100.0;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String plain(String withColours) { return withColours.replaceAll("§.", ""); }
|
||||
|
||||
public static String duration(long seconds) {
|
||||
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m " + (seconds % 60) + "s";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
import net.scopenet.integration.ChunkCheckResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
|
||||
final class GuildCommands {
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline");
|
||||
|
||||
private final Env env;
|
||||
private final Jobs jobs;
|
||||
private final EconomyCommands economy;
|
||||
private final EssentialsService ess;
|
||||
private final Path homesFile;
|
||||
private final Map<String, Pos> guildHomes = new ConcurrentHashMap<>();
|
||||
|
||||
GuildCommands(Env env, Jobs jobs, EconomyCommands economy, EssentialsService ess, Path homesFile) {
|
||||
this.env = env; this.jobs = jobs; this.economy = economy; this.ess = ess; this.homesFile = homesFile;
|
||||
if (Files.exists(homesFile)) {
|
||||
try (Reader in = Files.newBufferedReader(homesFile)) {
|
||||
Map<String, Pos> loaded = GSON.fromJson(in, new com.google.gson.reflect.TypeToken<Map<String, Pos>>() {}.getType());
|
||||
if (loaded != null) guildHomes.putAll(loaded);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("Could not read " + homesFile + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<CoreCommand> build() {
|
||||
List<CoreCommand> out = new ArrayList<>();
|
||||
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
|
||||
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline")
|
||||
: a.length == 2 && a[0].equalsIgnoreCase("invite") ? EssentialsCommands.names(env, p) : List.of()));
|
||||
out.add(Cmd.of("claim", List.of(), (p, a) -> claim(p)));
|
||||
out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p)));
|
||||
return out;
|
||||
}
|
||||
|
||||
private void guild(CorePlayer p, String[] args) {
|
||||
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
|
||||
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
|
||||
if (!p.hasPermission("scopenet.command.guild.info")) { p.send(Format.RED + "You do not have permission to use /guild info."); return; }
|
||||
info(p);
|
||||
return;
|
||||
}
|
||||
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||
String node = sub.equals("c") ? "chat" : sub;
|
||||
if (GATED.contains(node) && !p.hasPermission("scopenet.command.guild." + node)) {
|
||||
p.send(Format.RED + "You do not have permission to use /guild " + node + ".");
|
||||
return;
|
||||
}
|
||||
String detail = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
|
||||
String fine = sub.equals("sell") && detail.equals("hand") ? "sell.hand"
|
||||
: sub.equals("market") && (detail.equals("sell") || detail.equals("buy")) ? "market." + detail : null;
|
||||
if (fine != null && !p.hasPermission("scopenet.command.guild." + fine)) {
|
||||
p.send(Format.RED + "You do not have permission to use /guild " + sub + " " + detail + ".");
|
||||
return;
|
||||
}
|
||||
String[] rest = Arrays.copyOfRange(args, 1, args.length);
|
||||
switch (sub) {
|
||||
case "create" -> create(p, args);
|
||||
case "leave" -> leave(p);
|
||||
case "claim" -> claim(p);
|
||||
case "unclaim" -> unclaim(p);
|
||||
case "map" -> map(p);
|
||||
case "chat", "c" -> chat(p, args);
|
||||
case "sethome" -> setHome(p);
|
||||
case "home" -> home(p);
|
||||
case "members" -> members(p);
|
||||
case "bank" -> bank(p, rest);
|
||||
case "pay" -> pay(p, rest);
|
||||
case "invite" -> invite(p, rest);
|
||||
case "accept" -> answer(p, rest, true);
|
||||
case "decline", "deny" -> answer(p, rest, false);
|
||||
case "sell" -> economy.sell(p, rest, true);
|
||||
case "market" -> economy.market(p, rest, true);
|
||||
default -> help(p);
|
||||
}
|
||||
}
|
||||
|
||||
private void help(CorePlayer p) {
|
||||
p.send(Format.GOLD + "=== SCOPENET Guild Commands ===");
|
||||
String[][] rows = {
|
||||
{"/guild", "Your guild overview"}, {"/guild create <name> <tag>", "Create a new guild"}, {"/guild leave", "Leave your current guild"},
|
||||
{"/claim", "Claim current chunk (also /guild claim)"}, {"/unclaim", "Unclaim current chunk (also /guild unclaim)"},
|
||||
{"/guild map", "Show nearby land claims"}, {"/guild chat <msg>", "Send message to guild members"},
|
||||
{"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw <amount>]", "Guild money"},
|
||||
{"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell <price> | buy <#id>", "Trade on the market as a guild"},
|
||||
{"/guild pay <tag> <amount>", "Pay another guild"},
|
||||
{"/guild invite <player>", "Invite a player (leaders and officers)"}, {"/guild accept [tag] | decline [tag]", "Answer a guild invitation"},
|
||||
};
|
||||
for (String[] r : rows) p.send(Format.YELLOW + r[0] + Format.GRAY + " - " + r[1]);
|
||||
}
|
||||
|
||||
private JsonObject uuidBody(CorePlayer p) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("uuid", p.uuid().toString());
|
||||
return o;
|
||||
}
|
||||
|
||||
private JsonObject chunkBody(CorePlayer p) {
|
||||
Pos pos = p.pos();
|
||||
JsonObject o = uuidBody(p);
|
||||
o.addProperty("dimension", pos.world());
|
||||
o.addProperty("chunk_x", pos.blockX() >> 4);
|
||||
o.addProperty("chunk_z", pos.blockZ() >> 4);
|
||||
return o;
|
||||
}
|
||||
|
||||
void claim(CorePlayer p) {
|
||||
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
|
||||
JsonObject body = chunkBody(p);
|
||||
p.send(Format.GRAY + "Claiming chunk [" + body.get("chunk_x").getAsInt() + ", " + body.get("chunk_z").getAsInt() + "]...");
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/claim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
|
||||
String name = r.has("guild_name") ? r.get("guild_name").getAsString() : "your guild";
|
||||
p.send(Format.GREEN + "Chunk claimed successfully for " + Format.YELLOW + name + Format.GREEN + "!");
|
||||
}, e -> p.send(Format.RED + "Claim failed: " + e));
|
||||
}
|
||||
|
||||
void unclaim(CorePlayer p) {
|
||||
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
|
||||
JsonObject body = chunkBody(p);
|
||||
int cx = body.get("chunk_x").getAsInt(), cz = body.get("chunk_z").getAsInt();
|
||||
p.send(Format.GRAY + "Unclaiming chunk [" + cx + ", " + cz + "]...");
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/unclaim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
|
||||
r -> p.send(Format.GREEN + "Chunk [" + cx + ", " + cz + "] has been unclaimed."), e -> p.send(Format.RED + "Unclaim failed: " + e));
|
||||
}
|
||||
|
||||
private void create(CorePlayer p, String[] args) {
|
||||
if (args.length < 3) { p.send(Format.RED + "Usage: /guild create <name> <tag>"); return; }
|
||||
String name = args[1], tag = args[2];
|
||||
p.send(Format.GRAY + "Founding guild " + name + " [" + tag + "]...");
|
||||
JsonObject body = uuidBody(p);
|
||||
body.addProperty("username", p.name());
|
||||
body.addProperty("name", name);
|
||||
body.addProperty("tag", tag);
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/create", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
|
||||
r -> p.send(Format.GREEN + "Guild " + Format.YELLOW + name + Format.GOLD + " [" + tag + "]" + Format.GREEN + " founded successfully! You are the Guild Leader."),
|
||||
e -> p.send(Format.RED + "Failed to create guild: " + e));
|
||||
}
|
||||
|
||||
private void leave(CorePlayer p) {
|
||||
p.send(Format.GRAY + "Leaving guild...");
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/leave", uuidBody(p)).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
|
||||
boolean disbanded = r.has("disbanded") && r.get("disbanded").getAsBoolean();
|
||||
p.send(disbanded ? Format.YELLOW + "You were the last member. The guild was disbanded." : Format.GREEN + "You left your guild.");
|
||||
}, e -> p.send(Format.RED + "Error leaving guild: " + e));
|
||||
}
|
||||
|
||||
/** The guild the player is in, or an error message sent to them. Runs {@code then} on the server thread. */
|
||||
private void withGuild(CorePlayer p, String notInGuild, java.util.function.Consumer<JsonObject> then) {
|
||||
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
|
||||
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) { p.send(Format.RED + notInGuild); return; }
|
||||
then.accept(r.getAsJsonObject("guild"));
|
||||
}, e -> p.send(Format.RED + "Error: " + e));
|
||||
}
|
||||
|
||||
private void info(CorePlayer p) {
|
||||
p.send(Format.GRAY + "Loading guild info...");
|
||||
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
|
||||
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) {
|
||||
p.send(Format.YELLOW + "You are not in a guild! Create one with " + Format.GOLD + "/guild create <name> <tag>");
|
||||
return;
|
||||
}
|
||||
JsonObject g = r.getAsJsonObject("guild");
|
||||
int members = g.has("members") ? g.getAsJsonArray("members").size() : 0;
|
||||
p.send(Format.GOLD + "=== Guild: " + Format.YELLOW + g.get("name").getAsString() + " " + Format.GOLD + "[" + g.get("tag").getAsString() + "] ===");
|
||||
p.send(Format.GRAY + "Your role: " + Format.GREEN + g.get("role").getAsString().toUpperCase(Locale.ROOT) + Format.GRAY + " Level: " + Format.GOLD
|
||||
+ (g.has("level") ? g.get("level").getAsInt() : 1) + Format.GRAY + " Members: " + Format.WHITE + members);
|
||||
p.send(Format.GRAY + "Claimed chunks: " + Format.YELLOW + (g.has("claims_count") ? g.get("claims_count").getAsLong() : 0)
|
||||
+ Format.GRAY + " / " + Format.GREEN + (g.has("max_claims") ? g.get("max_claims").getAsLong() : 16));
|
||||
p.send(Format.GRAY + "/claim, /unclaim, /guild map, /guild members, /guild bank");
|
||||
}, e -> p.send(Format.RED + "Failed to load guild: " + e));
|
||||
}
|
||||
|
||||
private void map(CorePlayer p) {
|
||||
Pos pos = p.pos();
|
||||
int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4;
|
||||
p.send(Format.GOLD + "========= " + Format.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + Format.GOLD + " =========");
|
||||
p.send(Format.GRAY + " N (-Z)");
|
||||
for (int dz = -3; dz <= 3; dz++) {
|
||||
StringBuilder row = new StringBuilder(" ");
|
||||
for (int dx = -4; dx <= 4; dx++) {
|
||||
if (dx == 0 && dz == 0) { row.append(Format.GOLD).append("[P]"); continue; }
|
||||
ChunkCheckResult res = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid());
|
||||
if (res.claimed() && res.allowed()) row.append(Format.GREEN).append(" + ");
|
||||
else if (res.claimed()) row.append(Format.RED).append(" x ");
|
||||
else row.append(Format.DARK_GRAY).append(" . ");
|
||||
}
|
||||
p.send(row.toString());
|
||||
}
|
||||
p.send(Format.GRAY + " S (+Z)");
|
||||
p.send(Format.GOLD + "[P]" + Format.YELLOW + " You " + Format.GREEN + "+ " + Format.GRAY + "Your Guild " + Format.RED + "x " + Format.GRAY + "Other Guild "
|
||||
+ Format.DARK_GRAY + ". " + Format.GRAY + "Wilderness");
|
||||
}
|
||||
|
||||
private void chat(CorePlayer p, String[] args) {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /guild chat <message>"); return; }
|
||||
String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
|
||||
withGuild(p, "You are not in a guild.", g -> {
|
||||
String tag = g.get("tag").getAsString();
|
||||
Set<String> names = new HashSet<>();
|
||||
if (g.has("members")) g.getAsJsonArray("members").forEach(m -> names.add(m.getAsJsonObject().get("name").getAsString().toLowerCase(Locale.ROOT)));
|
||||
String line = Format.GREEN + "[Guild " + tag + "] " + Format.YELLOW + p.name() + ": " + Format.WHITE + message;
|
||||
// Only guild members read guild chat.
|
||||
for (CorePlayer other : env.platform.online()) if (names.contains(other.name().toLowerCase(Locale.ROOT)) || other.uuid().equals(p.uuid())) other.send(line);
|
||||
});
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
if (homesFile.getParent() != null) Files.createDirectories(homesFile.getParent());
|
||||
Path tmp = homesFile.resolveSibling(homesFile.getFileName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(new TreeMap<>(guildHomes), w); }
|
||||
Files.move(tmp, homesFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
env.log.warning("Failed to save guild homes: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void setHome(CorePlayer p) {
|
||||
withGuild(p, "You must be in a guild to set a guild home.", g -> {
|
||||
String role = g.get("role").getAsString();
|
||||
if (!role.equals("leader") && !role.equals("officer")) { p.send(Format.RED + "Only guild leaders and officers can set the guild home."); return; }
|
||||
guildHomes.put(g.get("id").getAsString(), p.pos());
|
||||
save();
|
||||
p.send(Format.GREEN + "Guild home waypoint set at your location!");
|
||||
});
|
||||
}
|
||||
|
||||
private void home(CorePlayer p) {
|
||||
withGuild(p, "You are not in a guild.", g -> {
|
||||
Pos at = guildHomes.get(g.get("id").getAsString());
|
||||
if (at == null) { p.send(Format.RED + "Your guild has not set a guild home yet! Use /guild sethome"); return; }
|
||||
ess.teleportTo(p, at);
|
||||
p.send(Format.GREEN + "Teleported to guild home base.");
|
||||
});
|
||||
}
|
||||
|
||||
private void members(CorePlayer p) {
|
||||
withGuild(p, "You are not in a guild.", g -> {
|
||||
p.send(Format.GOLD + "=== Members of " + Format.YELLOW + g.get("name").getAsString() + Format.GOLD + " ===");
|
||||
for (JsonElement el : g.getAsJsonArray("members")) {
|
||||
JsonObject m = el.getAsJsonObject();
|
||||
String name = m.get("name").getAsString();
|
||||
String status = env.platform.playerByName(name).isPresent() ? Format.GREEN + "[Online]" : Format.GRAY + "[Offline]";
|
||||
p.send(Format.WHITE + " - " + name + " (" + Format.AQUA + m.get("role").getAsString() + Format.WHITE + ") " + status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- invitations ------------------------------------------------------------
|
||||
|
||||
private void invite(CorePlayer p, String[] args) {
|
||||
if (args.length < 1) { p.send(Format.RED + "Usage: /guild invite <player>"); return; }
|
||||
JsonObject body = uuidBody(p);
|
||||
body.addProperty("target", args[0]);
|
||||
env.io(() -> env.panel.call("guilds/invite/send", body).getAsJsonObject(),
|
||||
r -> p.send(Format.GREEN + "Invitation sent to " + Format.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[0]) + Format.GREEN + "."),
|
||||
e -> p.send(Format.RED + "Could not invite: " + e));
|
||||
}
|
||||
|
||||
private void answer(CorePlayer p, String[] args, boolean accept) {
|
||||
JsonObject body = uuidBody(p);
|
||||
body.addProperty("tag", args.length > 0 ? args[0] : "");
|
||||
body.addProperty("accept", accept);
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/invite/respond", body).getAsJsonObject(); if (accept) env.panel.claimsChanged(); return r; },
|
||||
r -> p.send(accept ? Format.GREEN + "Welcome to " + Format.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + Format.GREEN + "!"
|
||||
: Format.YELLOW + "Invitation declined."),
|
||||
e -> p.send(Format.RED + e));
|
||||
}
|
||||
|
||||
/** Guild homes by guild id, for the map. */
|
||||
Map<String, Pos> guildHomes() { return Map.copyOf(guildHomes); }
|
||||
|
||||
// ---- bank ---------------------------------------------------------------
|
||||
|
||||
private void bank(CorePlayer p, String[] args) {
|
||||
if (args.length >= 2 && (args[0].equalsIgnoreCase("deposit") || args[0].equalsIgnoreCase("withdraw"))) {
|
||||
String action = args[0].toLowerCase(Locale.ROOT);
|
||||
if (!p.hasPermission("scopenet.command.guild.bank." + action)) { p.send(Format.RED + "You do not have permission to " + action + " guild money."); return; }
|
||||
Double value = Format.amount(args[1]);
|
||||
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
|
||||
JsonObject payload = uuidBody(p);
|
||||
payload.addProperty("username", p.name());
|
||||
payload.addProperty("amount", value);
|
||||
payload.addProperty("action", action);
|
||||
jobs.enqueue(p, "guilds/bank/transfer", payload, List.of(), null);
|
||||
return;
|
||||
}
|
||||
if (args.length > 0 && !args[0].equalsIgnoreCase("balance") && !args[0].equalsIgnoreCase("info")) {
|
||||
p.send(Format.RED + "Usage: /guild bank [deposit|withdraw <amount>]");
|
||||
return;
|
||||
}
|
||||
env.io(() -> env.panel.call("guilds/bank", uuidBody(p)).getAsJsonObject(), info -> {
|
||||
if (!info.has("guild") || info.get("guild").isJsonNull()) { p.send(Format.RED + "You are not in a guild on this server."); return; }
|
||||
JsonObject g = info.getAsJsonObject("guild");
|
||||
p.send(Format.GOLD + "=== [" + g.get("tag").getAsString() + "] " + g.get("name").getAsString() + " Bank ===");
|
||||
p.send(Format.YELLOW + "Balance: " + Format.GREEN + env.money(info.get("balance").getAsDouble()) + Format.GRAY + " Your wallet: "
|
||||
+ env.money(info.get("my_balance").getAsDouble()) + " Role: " + info.get("role").getAsString());
|
||||
for (JsonElement el : info.getAsJsonArray("recent")) {
|
||||
JsonObject row = el.getAsJsonObject();
|
||||
String kind = row.get("kind").getAsString();
|
||||
boolean out = kind.equals("withdraw") || kind.equals("purchase") || kind.equals("transfer_out");
|
||||
p.send(Format.GRAY + " " + (out ? Format.RED + "-" : Format.GREEN + "+") + env.money(row.get("amount").getAsDouble()) + Format.GRAY + " "
|
||||
+ kind.replace('_', ' ') + " by " + row.get("who").getAsString());
|
||||
}
|
||||
p.send(Format.GRAY + "/guild bank deposit|withdraw <amount>, /guild sell, /guild market, /guild pay <tag> <amount>");
|
||||
}, e -> p.send(Format.RED + "Could not load the guild bank: " + e));
|
||||
}
|
||||
|
||||
private void pay(CorePlayer p, String[] args) {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /guild pay <guild tag> <amount>"); return; }
|
||||
Double value = Format.amount(args[1]);
|
||||
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
|
||||
JsonObject payload = uuidBody(p);
|
||||
payload.addProperty("to_tag", args[0]);
|
||||
payload.addProperty("amount", value);
|
||||
jobs.enqueue(p, "guilds/bank/pay", payload, List.of(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
/**
|
||||
* A stack of items in a platform-neutral form. {@code id} is what the panel stores ("DIAMOND", or
|
||||
* "modid:item" for modded items); {@code data} is the platform's own exact serialisation (used to return
|
||||
* or deliver the very same stack). When {@code data} is empty the platform rebuilds the stack from id and count.
|
||||
*/
|
||||
public record Item(String id, String name, int count, String data) {
|
||||
/** Panel id for a registry id: minecraft items become "DIAMOND", others stay "modid:item". */
|
||||
public static String panelId(String registryId) {
|
||||
String s = registryId.toLowerCase(java.util.Locale.ROOT);
|
||||
return s.startsWith("minecraft:") ? s.substring("minecraft:".length()).toUpperCase(java.util.Locale.ROOT) : s;
|
||||
}
|
||||
|
||||
/** The registry id for a panel id (inverse of {@link #panelId}). */
|
||||
public static String registryId(String panelId) {
|
||||
return panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** What the server shop pays per item (same table as the Paper plugin). Keys are panel item ids. */
|
||||
public final class ItemValues {
|
||||
private static final Map<String, Double> VALUES = new TreeMap<>();
|
||||
static {
|
||||
Object[][] table = {
|
||||
{"COBBLESTONE", 0.1}, {"STONE", 0.2}, {"OAK_LOG", 0.5}, {"BIRCH_LOG", 0.5}, {"SPRUCE_LOG", 0.5}, {"COAL", 1.0},
|
||||
{"RAW_COPPER", 1.5}, {"COPPER_INGOT", 2.0}, {"RAW_IRON", 3.0}, {"IRON_INGOT", 4.0}, {"RAW_GOLD", 6.0},
|
||||
{"GOLD_INGOT", 8.0}, {"REDSTONE", 1.5}, {"LAPIS_LAZULI", 2.0}, {"DIAMOND", 35.0}, {"EMERALD", 20.0},
|
||||
{"NETHERITE_SCRAP", 150.0}, {"NETHERITE_INGOT", 650.0}, {"WHEAT", 0.5}, {"CARROT", 0.5}, {"POTATO", 0.5},
|
||||
{"BEEF", 1.0}, {"COOKED_BEEF", 2.0}, {"PORKCHOP", 1.0}, {"COOKED_PORKCHOP", 2.0},
|
||||
};
|
||||
for (Object[] row : table) VALUES.put((String) row[0], (Double) row[1]);
|
||||
}
|
||||
|
||||
private ItemValues() {}
|
||||
|
||||
public static Double unitPrice(String panelId) { return VALUES.get(panelId.toUpperCase(Locale.ROOT)); }
|
||||
|
||||
public static Map<String, Double> all() { return java.util.Collections.unmodifiableMap(VALUES); }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Money-and-items transactions that must never lose either. The held items are taken and written to disk first
|
||||
* ("escrow"), the panel call carries an operation id so a retry can't double-pay, and the items are only
|
||||
* returned or delivered once the panel has answered. Survives crashes and panel outages. Server thread only.
|
||||
*/
|
||||
public final class Jobs {
|
||||
private final Env env;
|
||||
private final Path file;
|
||||
private final Map<String, JsonObject> jobs = new LinkedHashMap<>();
|
||||
private final Set<String> running = new HashSet<>();
|
||||
|
||||
public Jobs(Env env, Path file) {
|
||||
this.env = env;
|
||||
this.file = file;
|
||||
if (Files.exists(file)) {
|
||||
try (Reader in = Files.newBufferedReader(file)) {
|
||||
JsonObject all = JsonParser.parseReader(in).getAsJsonObject();
|
||||
all.entrySet().forEach(e -> jobs.put(e.getKey(), e.getValue().getAsJsonObject()));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int pending() { return jobs.size(); }
|
||||
|
||||
private static JsonObject encode(Item item) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", item.id());
|
||||
o.addProperty("name", item.name());
|
||||
o.addProperty("count", item.count());
|
||||
o.addProperty("data", item.data());
|
||||
return o;
|
||||
}
|
||||
|
||||
private static Item decode(JsonElement e) {
|
||||
JsonObject o = e.getAsJsonObject();
|
||||
return new Item(o.get("id").getAsString(), o.get("name").getAsString(), o.get("count").getAsInt(), o.get("data").getAsString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue {@code endpoint} with {@code payload}. {@code escrow} were taken from the player and are returned if the
|
||||
* panel refuses. {@code purchase} is delivered once it accepts. Returns false (and queues nothing) if it can't be saved.
|
||||
*/
|
||||
public boolean enqueue(CorePlayer player, String endpoint, JsonObject payload, List<Item> escrow, Item purchase) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
payload.addProperty("operation_id", id);
|
||||
JsonObject job = new JsonObject();
|
||||
job.addProperty("player", player.uuid().toString());
|
||||
job.addProperty("endpoint", endpoint);
|
||||
job.add("payload", payload);
|
||||
JsonArray held = new JsonArray();
|
||||
escrow.forEach(i -> held.add(encode(i)));
|
||||
job.add("escrow", held);
|
||||
if (purchase != null) job.add("purchase", encode(purchase));
|
||||
jobs.put(id, job);
|
||||
try { save(); } catch (IOException e) {
|
||||
jobs.remove(id);
|
||||
player.send(Format.RED + "Cannot save transaction; nothing was purchased or sold.");
|
||||
return false;
|
||||
}
|
||||
player.send(Format.GRAY + "Transaction queued. Items are kept safe while the panel responds.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void save() throws IOException {
|
||||
JsonObject all = new JsonObject();
|
||||
jobs.forEach(all::add);
|
||||
if (file.getParent() != null) Files.createDirectories(file.getParent());
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp)) { w.write(all.toString()); }
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
/** Call every few seconds on the server thread. Sends waiting jobs and delivers finished ones. */
|
||||
public void tick() {
|
||||
for (Map.Entry<String, JsonObject> entry : new ArrayList<>(jobs.entrySet())) {
|
||||
String id = entry.getKey();
|
||||
JsonObject job = entry.getValue();
|
||||
if (job.has("result") || job.has("error")) { deliver(id, job); continue; }
|
||||
if (!running.add(id)) continue;
|
||||
String endpoint = job.get("endpoint").getAsString();
|
||||
JsonObject payload = job.getAsJsonObject("payload").deepCopy();
|
||||
env.platform.runAsync(() -> {
|
||||
JsonElement result = null;
|
||||
String error = null;
|
||||
try { result = env.panel.call(endpoint, payload); }
|
||||
catch (net.scopenet.integration.PanelClient.HttpFailure e) { if (e.status >= 400 && e.status < 500) error = e.getMessage(); }
|
||||
catch (Exception e) { /* Unknown outcome: retry the same operation id; a credit is never duplicated. */ }
|
||||
final JsonElement response = result;
|
||||
final String failure = error;
|
||||
env.platform.runMain(() -> {
|
||||
running.remove(id);
|
||||
if (response != null && response.isJsonObject()) job.add("result", response);
|
||||
if (failure != null) job.addProperty("error", failure);
|
||||
try { save(); } catch (IOException e) { env.log.severe("Could not persist economy result: " + e.getMessage()); return; }
|
||||
if (job.has("result") || job.has("error")) deliver(id, job);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void deliver(String id, JsonObject job) {
|
||||
Optional<CorePlayer> found = env.platform.player(UUID.fromString(job.get("player").getAsString()));
|
||||
if (found.isEmpty()) return; // waits until they are back
|
||||
CorePlayer player = found.get();
|
||||
try {
|
||||
List<Item> items = new ArrayList<>();
|
||||
boolean failed = job.has("error");
|
||||
String endpoint = job.get("endpoint").getAsString();
|
||||
if (failed) {
|
||||
job.getAsJsonArray("escrow").forEach(e -> items.add(decode(e)));
|
||||
} else if (job.has("purchase")) {
|
||||
items.add(decode(job.get("purchase")));
|
||||
} else if (endpoint.equals("economy/market/buy")) {
|
||||
JsonObject result = job.getAsJsonObject("result");
|
||||
if (result.has("item_data") && !result.get("item_data").isJsonNull() && !result.get("item_data").getAsString().isEmpty()) {
|
||||
// Another platform's exact stack can't be rebuilt here, so fall back to id and count.
|
||||
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(),
|
||||
result.get("item_data").getAsString()));
|
||||
} else {
|
||||
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(), ""));
|
||||
}
|
||||
}
|
||||
items.forEach(player::give);
|
||||
jobs.remove(id);
|
||||
save();
|
||||
String symbol = env.features.currencySymbol();
|
||||
if (failed) {
|
||||
player.send(Format.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
|
||||
return;
|
||||
}
|
||||
JsonObject result = job.getAsJsonObject("result");
|
||||
if (result.has("message") && result.get("message").isJsonPrimitive()) {
|
||||
JsonElement bal = result.get("balance");
|
||||
String bank = bal != null && bal.isJsonPrimitive() ? " Bank: " + Format.money(symbol, bal.getAsDouble()) : "";
|
||||
player.send(Format.GREEN + result.get("message").getAsString() + bank);
|
||||
} else {
|
||||
JsonElement bal = result.has("new_balance") ? result.get("new_balance") : result.get("balance");
|
||||
player.send(Format.GREEN + "Transaction completed." + (bal != null && bal.isJsonPrimitive() ? " Balance: " + Format.money(symbol, bal.getAsDouble()) : ""));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
env.log.severe("Economy delivery requires attention for " + id + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.integration.ChunkCheckResult;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** The panel calls the shared logic makes. {@link PanelAdapter} implements it over HTTP; tests use a fake. */
|
||||
public interface Panel {
|
||||
/** POST to {@code /api/server/v1/<endpoint>} and return the JSON answer. Throws with the panel's message on errors. */
|
||||
com.google.gson.JsonElement call(String endpoint, JsonObject body) throws Exception;
|
||||
|
||||
/** Answered from the local claim index; never blocks on the network. */
|
||||
/** Tell the claim index to re-sync soon (after claiming, unclaiming or founding a guild). */
|
||||
default void claimsChanged() {}
|
||||
|
||||
ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.integration.ChunkCheckResult;
|
||||
import net.scopenet.integration.Integration;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PanelAdapter implements Panel {
|
||||
private final Integration integration;
|
||||
public PanelAdapter(Integration integration) { this.integration = integration; }
|
||||
|
||||
@Override public com.google.gson.JsonElement call(String endpoint, JsonObject body) throws Exception {
|
||||
return integration.client().postElement(endpoint, body);
|
||||
}
|
||||
|
||||
@Override public void claimsChanged() { integration.client().requestClaimRefresh(); }
|
||||
|
||||
@Override public ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player) {
|
||||
return integration.checkChunk(dimension, chunkX, chunkZ, player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* %scopenet_*% values, the same names and formats as the Paper PlaceholderAPI expansion. On Fabric they appear as
|
||||
* %scopenet:level% (Placeholder API). Reads the {@link PlayerCache}, so it never blocks.
|
||||
*/
|
||||
public final class Placeholders {
|
||||
public static final List<String> KEYS = List.of(
|
||||
"level", "xp", "level_xp", "next_level_xp", "level_progress", "level_bar", "title", "leaderboard_rank",
|
||||
"server_level", "server_xp", "server_rank", "guild", "guild_tag", "guild_role", "guild_claims", "has_guild",
|
||||
"balance", "balance_formatted", "playtime", "playtime_seconds", "playtime_hours", "server_playtime",
|
||||
"quest_progress", "quest_daily", "quest_weekly", "quest_daily_done", "quest_daily_total", "quest_weekly_done", "quest_weekly_total",
|
||||
"kills", "deaths", "kdr", "friends", "achievements", "joined", "rank", "rank_prefix");
|
||||
|
||||
private final PlayerCache cache;
|
||||
private final Env env;
|
||||
|
||||
public Placeholders(Env env, PlayerCache cache) { this.env = env; this.cache = cache; }
|
||||
|
||||
/** Empty means "not one of ours". Before the first panel answer, sensible zero values are returned. */
|
||||
public Optional<String> resolve(UUID player, String key) {
|
||||
String k = key.toLowerCase(Locale.ROOT);
|
||||
if (!KEYS.contains(k)) return Optional.empty();
|
||||
JsonObject i = cache.info(player);
|
||||
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild");
|
||||
JsonObject quests = PlayerCache.obj(i, "quests"), daily = PlayerCache.obj(quests, "daily"), weekly = PlayerCache.obj(quests, "weekly");
|
||||
JsonObject rank = PlayerCache.obj(i, "rank");
|
||||
double balance = PlayerCache.num(i, "balance", 0);
|
||||
long playtime = (long) PlayerCache.num(i, "playtime_secs", 0);
|
||||
long kills = (long) PlayerCache.num(i, "kills", 0), deaths = (long) PlayerCache.num(i, "deaths", 0);
|
||||
int dDone = (int) PlayerCache.num(daily, "completed", 0), dTotal = (int) PlayerCache.num(daily, "total", 0);
|
||||
int wDone = (int) PlayerCache.num(weekly, "completed", 0), wTotal = (int) PlayerCache.num(weekly, "total", 0);
|
||||
String symbol = env.features.currencySymbol();
|
||||
String out = switch (k) {
|
||||
case "level" -> String.valueOf((long) PlayerCache.num(global, "level", 1));
|
||||
case "xp" -> String.valueOf((long) PlayerCache.num(global, "xp", 0));
|
||||
case "level_xp" -> String.valueOf((long) PlayerCache.num(global, "current_level_xp", 0));
|
||||
case "next_level_xp" -> String.valueOf((long) PlayerCache.num(global, "next_level_xp", 0));
|
||||
case "level_progress" -> String.valueOf(Math.round(PlayerCache.num(global, "progress_pct", 0)));
|
||||
case "level_bar" -> bar(PlayerCache.num(global, "progress_pct", 0));
|
||||
case "title" -> PlayerCache.str(global, "title", "");
|
||||
case "leaderboard_rank" -> { long r = (long) PlayerCache.num(global, "rank", 0); yield r <= 0 ? "-" : String.valueOf(r); }
|
||||
case "server_level" -> String.valueOf((long) PlayerCache.num(server, "level", 1));
|
||||
case "server_xp" -> String.valueOf((long) PlayerCache.num(server, "xp", 0));
|
||||
case "server_rank" -> PlayerCache.str(server, "rank_name", "");
|
||||
case "guild" -> PlayerCache.str(guild, "name", "");
|
||||
case "guild_tag" -> PlayerCache.str(guild, "tag", "");
|
||||
case "guild_role" -> PlayerCache.str(guild, "role", "");
|
||||
case "guild_claims" -> String.valueOf((long) PlayerCache.num(guild, "claims", 0));
|
||||
case "has_guild" -> String.valueOf(guild != null);
|
||||
case "balance" -> String.format(Locale.US, "%.2f", balance);
|
||||
case "balance_formatted" -> Format.money(symbol, balance);
|
||||
case "playtime" -> duration(playtime);
|
||||
case "playtime_seconds" -> String.valueOf(playtime);
|
||||
case "playtime_hours" -> String.valueOf(playtime / 3600);
|
||||
case "server_playtime" -> duration((long) PlayerCache.num(i, "server_playtime_secs", 0));
|
||||
case "quest_progress" -> (dDone + wDone) + "/" + (dTotal + wTotal);
|
||||
case "quest_daily" -> dDone + "/" + dTotal;
|
||||
case "quest_weekly" -> wDone + "/" + wTotal;
|
||||
case "quest_daily_done" -> String.valueOf(dDone);
|
||||
case "quest_daily_total" -> String.valueOf(dTotal);
|
||||
case "quest_weekly_done" -> String.valueOf(wDone);
|
||||
case "quest_weekly_total" -> String.valueOf(wTotal);
|
||||
case "kills" -> String.valueOf(kills);
|
||||
case "deaths" -> String.valueOf(deaths);
|
||||
case "kdr" -> String.format(Locale.US, "%.2f", deaths == 0 ? (double) kills : (double) kills / deaths);
|
||||
case "friends" -> String.valueOf((long) PlayerCache.num(i, "friends", 0));
|
||||
case "achievements" -> String.valueOf((long) PlayerCache.num(i, "achievements", 0));
|
||||
case "joined" -> { String j = PlayerCache.str(i, "joined", ""); yield j.length() >= 10 ? j.substring(0, 10) : j; }
|
||||
case "rank" -> PlayerCache.str(rank, "group", "");
|
||||
case "rank_prefix" -> PlayerCache.str(rank, "prefix", "");
|
||||
default -> "";
|
||||
};
|
||||
return Optional.of(out);
|
||||
}
|
||||
|
||||
static String bar(double percent) {
|
||||
int filled = (int) Math.max(0, Math.min(10, Math.round(percent / 10.0)));
|
||||
return "§a" + "■".repeat(filled) + "§7" + "■".repeat(10 - filled);
|
||||
}
|
||||
|
||||
static String duration(long seconds) {
|
||||
long d = seconds / 86400, h = seconds % 86400 / 3600, m = seconds % 3600 / 60;
|
||||
if (d > 0) return d + "d " + h + "h";
|
||||
if (h > 0) return h + "h " + m + "m";
|
||||
return m + "m";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** The server side of the adapter: who is online, the world, and threads. */
|
||||
public interface Platform {
|
||||
Optional<CorePlayer> player(UUID uuid);
|
||||
Optional<CorePlayer> playerByName(String name);
|
||||
Collection<? extends CorePlayer> online();
|
||||
/**
|
||||
* The spot to stand on at (x, z): the top solid, non-liquid block. Empty if unsafe or unloadable.
|
||||
* Called on the server thread.
|
||||
*/
|
||||
Optional<Pos> safeSurface(String world, int x, int z);
|
||||
Pos worldSpawn(String world);
|
||||
/** Run on the server thread (may run immediately if already there). */
|
||||
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,81 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* The panel's view of each online player (level, guild, balance, quests…), refreshed in the background so
|
||||
* placeholders and the client HUD never wait on the network.
|
||||
*/
|
||||
public final class PlayerCache {
|
||||
public static final long REFRESH_MS = 20_000;
|
||||
|
||||
private record Entry(JsonObject info, long at) {}
|
||||
|
||||
private final Env env;
|
||||
private final Map<UUID, Entry> entries = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
public PlayerCache(Env env) { this.env = env; }
|
||||
|
||||
/** The last answer for this player, or null if none yet. */
|
||||
public JsonObject info(UUID uuid) {
|
||||
Entry e = entries.get(uuid);
|
||||
return e == null ? null : e.info();
|
||||
}
|
||||
|
||||
public void forget(UUID uuid) { entries.remove(uuid); inFlight.remove(uuid); }
|
||||
|
||||
/** Fetch now (unless a fetch is already running); {@code done} runs on the server thread with the new info or null. */
|
||||
public void refresh(CorePlayer player, java.util.function.Consumer<JsonObject> done) {
|
||||
if (inFlight.putIfAbsent(player.uuid(), env.clock.getAsLong()) != null) return;
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("uuid", player.uuid().toString());
|
||||
env.io(() -> env.panel.call("player/info", body).getAsJsonObject(), info -> {
|
||||
inFlight.remove(player.uuid());
|
||||
if (info.has("exists") && !info.get("exists").getAsBoolean()) { if (done != null) done.accept(null); return; }
|
||||
entries.put(player.uuid(), new Entry(info, env.clock.getAsLong()));
|
||||
if (done != null) done.accept(info);
|
||||
}, e -> {
|
||||
inFlight.remove(player.uuid());
|
||||
if (done != null) done.accept(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep everyone online fresh. Call about once a second on the server thread. */
|
||||
public void tick(java.util.function.Consumer<CorePlayer> refreshed) {
|
||||
long now = env.clock.getAsLong();
|
||||
for (CorePlayer p : env.platform.online()) {
|
||||
Entry e = entries.get(p.uuid());
|
||||
if (e == null || now - e.at() >= REFRESH_MS) refresh(p, info -> { if (info != null && refreshed != null) refreshed.accept(p); });
|
||||
}
|
||||
entries.keySet().removeIf(id -> env.platform.player(id).isEmpty());
|
||||
}
|
||||
|
||||
// ---- small accessors used by placeholders and the client link -----------------------------
|
||||
|
||||
public static JsonObject obj(JsonObject o, String key) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
|
||||
}
|
||||
|
||||
public static double num(JsonObject o, String key, double fallback) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : fallback;
|
||||
}
|
||||
|
||||
public static String str(JsonObject o, String key, String fallback) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback;
|
||||
}
|
||||
|
||||
public static JsonArray arr(JsonObject o, String key) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
/** A spot in a world, independent of any server software. {@code world} is the dimension id, e.g. "minecraft:overworld". */
|
||||
public record Pos(String world, double x, double y, double z, float yaw, float pitch) {
|
||||
public int blockX() { return (int) Math.floor(x); }
|
||||
public int blockY() { return (int) Math.floor(y); }
|
||||
public int blockZ() { return (int) Math.floor(z); }
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Physical shops: a block in the world that opens the market or the server shop when a player right-clicks it, and
|
||||
* shows up as a pin on the map. The GUI itself is the same as typing the command, so it works on Paper (chest window)
|
||||
* and with the client mod (shop and market windows). Set up by staff with {@code /market point add|remove|list}.
|
||||
*/
|
||||
public final class ShopPoints {
|
||||
public record Shop(String name, String kind, Pos pos) {}
|
||||
|
||||
public static final String NODE = "scopenet.command.market.point";
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private final Path file;
|
||||
private final Map<String, Shop> shops = new ConcurrentHashMap<>();
|
||||
|
||||
public ShopPoints(Path file) {
|
||||
this.file = file;
|
||||
if (Files.exists(file)) {
|
||||
try (Reader in = Files.newBufferedReader(file)) {
|
||||
List<Shop> loaded = GSON.fromJson(in, new TypeToken<List<Shop>>() {}.getType());
|
||||
if (loaded != null) loaded.forEach(s -> shops.put(s.name().toLowerCase(Locale.ROOT), s));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Collection<Shop> all() { return new ArrayList<>(new TreeMap<>(shops).values()); }
|
||||
|
||||
/** The shop at this block, if any. */
|
||||
public Optional<Shop> at(String dimension, int x, int y, int z) {
|
||||
for (Shop s : shops.values()) {
|
||||
Pos p = s.pos();
|
||||
if (p.world().equals(dimension) && p.blockX() == x && p.blockY() == y && p.blockZ() == z) return Optional.of(s);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
if (file.getParent() != null) Files.createDirectories(file.getParent());
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(all(), w); }
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code /market point add <name> [market|shop]} marks the block the player is looking at... or, where the platform can't
|
||||
* tell, the block they stand on. {@code target} is that block. {@code remove <name>} and {@code list} as expected.
|
||||
*/
|
||||
public void command(CorePlayer p, String[] args, Pos target) {
|
||||
if (!p.hasPermission(NODE)) { p.send(Format.RED + "You do not have permission to manage shop points."); return; }
|
||||
String sub = args.length == 0 ? "list" : args[0].toLowerCase(Locale.ROOT);
|
||||
switch (sub) {
|
||||
case "add" -> {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /market point add <name> [market|shop]"); return; }
|
||||
String name = args[1];
|
||||
if (!name.matches("[A-Za-z0-9_-]{1,24}")) { p.send(Format.RED + "Names are 1-24 letters, digits, - or _."); return; }
|
||||
String kind = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : "market";
|
||||
if (!kind.equals("market") && !kind.equals("shop")) { p.send(Format.RED + "The kind is market or shop."); return; }
|
||||
if (at(target.world(), target.blockX(), target.blockY(), target.blockZ()).isPresent()) { p.send(Format.RED + "There is already a shop point on that block."); return; }
|
||||
Pos block = new Pos(target.world(), target.blockX() + 0.5, target.blockY(), target.blockZ() + 0.5, 0, 0);
|
||||
shops.put(name.toLowerCase(Locale.ROOT), new Shop(name, kind, block));
|
||||
save();
|
||||
p.send(Format.GREEN + "Shop point " + Format.YELLOW + name + Format.GREEN + " set (" + kind + "). Right-click that block to open the "
|
||||
+ (kind.equals("market") ? "market" : "shop") + ".");
|
||||
}
|
||||
case "remove", "delete" -> {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /market point remove <name>"); return; }
|
||||
if (shops.remove(args[1].toLowerCase(Locale.ROOT)) == null) { p.send(Format.RED + "No shop point called '" + args[1] + "'."); return; }
|
||||
save();
|
||||
p.send(Format.GREEN + "Shop point " + Format.YELLOW + args[1] + Format.GREEN + " removed.");
|
||||
}
|
||||
case "list" -> {
|
||||
p.send(Format.GOLD + "=== Shop points ===");
|
||||
if (shops.isEmpty()) p.send(Format.GRAY + "None yet. Look at a block and use /market point add <name>.");
|
||||
for (Shop s : all()) p.send(Format.YELLOW + s.name() + Format.GRAY + " (" + s.kind() + ") " + s.pos().blockX() + ", " + s.pos().blockY() + ", " + s.pos().blockZ());
|
||||
}
|
||||
default -> p.send(Format.RED + "Usage: /market point add <name> [market|shop] | remove <name> | list");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Encoding of client-link messages: a Minecraft "UTF string" (VarInt byte length, then UTF-8), same as FriendlyByteBuf.writeUtf. */
|
||||
public final class Wire {
|
||||
public static final String S2C = "scopenet:s2c";
|
||||
public static final String C2S = "scopenet:c2s";
|
||||
public static final int VERSION = 1;
|
||||
private static final int MAX_BYTES = 32767 * 3;
|
||||
|
||||
private Wire() {}
|
||||
|
||||
public static byte[] encode(String json) {
|
||||
byte[] body = json.getBytes(StandardCharsets.UTF_8);
|
||||
if (body.length > MAX_BYTES) throw new IllegalArgumentException("message too large");
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(body.length + 5);
|
||||
int v = body.length;
|
||||
while ((v & ~0x7F) != 0) { out.write((v & 0x7F) | 0x80); v >>>= 7; }
|
||||
out.write(v);
|
||||
out.writeBytes(body);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static String decode(byte[] data) {
|
||||
int length = 0, shift = 0, at = 0;
|
||||
while (true) {
|
||||
if (at >= data.length || shift > 28) throw new IllegalArgumentException("bad length");
|
||||
int b = data[at++];
|
||||
length |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
if (length < 0 || length > MAX_BYTES || at + length > data.length) throw new IllegalArgumentException("bad length");
|
||||
return new String(data, at, length, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Turns a set of claimed chunks into clean outlines for a map: one outer ring per connected area, plus a ring for
|
||||
* every hole inside it. Coordinates are block coordinates (chunk x 16). Pure and fast: each chunk adds at most four edges.
|
||||
*/
|
||||
public final class ChunkRegions {
|
||||
private ChunkRegions() {}
|
||||
|
||||
public record Point(double x, double z) {}
|
||||
|
||||
/** A closed loop; the last point connects back to the first. */
|
||||
public record Ring(List<Point> points) {
|
||||
public double area() {
|
||||
double sum = 0;
|
||||
for (int i = 0; i < points.size(); i++) {
|
||||
Point a = points.get(i), b = points.get((i + 1) % points.size());
|
||||
sum += a.x() * b.z() - b.x() * a.z();
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
public boolean contains(double x, double z) {
|
||||
boolean inside = false;
|
||||
for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) {
|
||||
Point a = points.get(i), b = points.get(j);
|
||||
if ((a.z() > z) != (b.z() > z) && x < (b.x() - a.x()) * (z - a.z()) / (b.z() - a.z()) + a.x()) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/** One connected area: its outline, the holes in it, how many chunks it has and a good spot for its label. */
|
||||
public record Region(Ring outer, List<Ring> holes, int chunks, double labelX, double labelZ) {}
|
||||
|
||||
public static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
static int kx(long key) { return (int) (key >> 32); }
|
||||
static int kz(long key) { return (int) key; }
|
||||
|
||||
/** Split into areas that touch along an edge. */
|
||||
static List<Set<Long>> components(Set<Long> chunks) {
|
||||
List<Set<Long>> out = new ArrayList<>();
|
||||
Set<Long> seen = new HashSet<>();
|
||||
List<Long> sorted = new ArrayList<>(chunks);
|
||||
Collections.sort(sorted);
|
||||
for (long start : sorted) {
|
||||
if (!seen.add(start)) continue;
|
||||
Set<Long> group = new HashSet<>();
|
||||
Deque<Long> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
group.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
long c = queue.poll();
|
||||
int x = kx(c), z = kz(c);
|
||||
for (long n : new long[]{key(x + 1, z), key(x - 1, z), key(x, z + 1), key(x, z - 1)}) {
|
||||
if (chunks.contains(n) && seen.add(n)) { group.add(n); queue.add(n); }
|
||||
}
|
||||
}
|
||||
out.add(group);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static List<Region> regions(Set<Long> chunks) {
|
||||
List<Region> out = new ArrayList<>();
|
||||
for (Set<Long> group : components(chunks)) {
|
||||
List<Ring> outers = new ArrayList<>(), holes = new ArrayList<>();
|
||||
for (Ring r : outline(group)) (r.area() > 0 ? outers : holes).add(r);
|
||||
double[] label = labelSpot(group);
|
||||
for (Ring o : outers) {
|
||||
List<Ring> mine = new ArrayList<>();
|
||||
for (Ring h : holes) if (outers.size() == 1 || o.contains(average(h, true), average(h, false))) mine.add(h);
|
||||
out.add(new Region(o, mine, group.size(), label[0], label[1]));
|
||||
}
|
||||
}
|
||||
out.sort(Comparator.comparingInt(Region::chunks).reversed().thenComparingDouble(r -> r.labelX()).thenComparingDouble(r -> r.labelZ()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double average(Ring ring, boolean x) {
|
||||
double sum = 0;
|
||||
for (Point p : ring.points()) sum += x ? p.x() : p.z();
|
||||
return sum / ring.points().size();
|
||||
}
|
||||
|
||||
/** The middle of the chunk nearest to the area's average, so a label never lands outside a bent shape. */
|
||||
static double[] labelSpot(Set<Long> group) {
|
||||
double mx = 0, mz = 0;
|
||||
for (long c : group) { mx += kx(c) + 0.5; mz += kz(c) + 0.5; }
|
||||
mx /= group.size();
|
||||
mz /= group.size();
|
||||
long best = group.iterator().next();
|
||||
double bestD = Double.MAX_VALUE;
|
||||
for (long c : group) {
|
||||
double d = Math.pow(kx(c) + 0.5 - mx, 2) + Math.pow(kz(c) + 0.5 - mz, 2);
|
||||
if (d < bestD || (d == bestD && c < best)) { bestD = d; best = c; }
|
||||
}
|
||||
return new double[]{(kx(best) + 0.5) * 16, (kz(best) + 0.5) * 16};
|
||||
}
|
||||
|
||||
private record Edge(int fx, int fz, int tx, int tz) {}
|
||||
|
||||
/** Boundary loops of one connected area. Outer loops have positive area, holes negative. */
|
||||
static List<Ring> outline(Set<Long> group) {
|
||||
// Directed edges with the chunk on the right-hand side (clockwise around solid land).
|
||||
Map<Long, List<Edge>> from = new HashMap<>();
|
||||
for (long c : group) {
|
||||
int x = kx(c), z = kz(c);
|
||||
if (!group.contains(key(x, z - 1))) add(from, new Edge(x, z, x + 1, z));
|
||||
if (!group.contains(key(x + 1, z))) add(from, new Edge(x + 1, z, x + 1, z + 1));
|
||||
if (!group.contains(key(x, z + 1))) add(from, new Edge(x + 1, z + 1, x, z + 1));
|
||||
if (!group.contains(key(x - 1, z))) add(from, new Edge(x, z + 1, x, z));
|
||||
}
|
||||
List<Ring> rings = new ArrayList<>();
|
||||
while (!from.isEmpty()) {
|
||||
// Start from a stable edge so results don't depend on hash order.
|
||||
long startKey = Collections.min(from.keySet());
|
||||
Edge first = from.get(startKey).get(0);
|
||||
List<int[]> loop = new ArrayList<>();
|
||||
Edge e = first;
|
||||
do {
|
||||
remove(from, e);
|
||||
loop.add(new int[]{e.fx(), e.fz()});
|
||||
e = next(from, e);
|
||||
if (e == null) break;
|
||||
} while (!(e.fx() == first.fx() && e.fz() == first.fz()));
|
||||
rings.add(new Ring(simplify(loop)));
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
private static void add(Map<Long, List<Edge>> m, Edge e) { m.computeIfAbsent(key(e.fx(), e.fz()), k -> new ArrayList<>()).add(e); }
|
||||
|
||||
private static void remove(Map<Long, List<Edge>> m, Edge e) {
|
||||
List<Edge> list = m.get(key(e.fx(), e.fz()));
|
||||
list.remove(e);
|
||||
if (list.isEmpty()) m.remove(key(e.fx(), e.fz()));
|
||||
}
|
||||
|
||||
/** The edge leaving this edge's end. Where two loops touch at a corner, turn right to keep them apart. */
|
||||
private static Edge next(Map<Long, List<Edge>> m, Edge e) {
|
||||
List<Edge> options = m.get(key(e.tx(), e.tz()));
|
||||
if (options == null || options.isEmpty()) return null;
|
||||
if (options.size() == 1) return options.get(0);
|
||||
int dx = e.tx() - e.fx(), dz = e.tz() - e.fz();
|
||||
Edge best = options.get(0);
|
||||
int bestScore = Integer.MIN_VALUE;
|
||||
for (Edge o : options) {
|
||||
int ox = o.tx() - o.fx(), oz = o.tz() - o.fz();
|
||||
int cross = dx * oz - dz * ox; // >0: turns right in (x right, z down) coordinates
|
||||
int score = cross > 0 ? 2 : cross == 0 ? 1 : 0;
|
||||
if (score > bestScore) { bestScore = score; best = o; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Drop corner points that sit in a straight line, then scale chunks to blocks. */
|
||||
private static List<Point> simplify(List<int[]> loop) {
|
||||
List<int[]> kept = new ArrayList<>();
|
||||
int n = loop.size();
|
||||
for (int i = 0; i < n; i++) {
|
||||
int[] prev = loop.get((i + n - 1) % n), cur = loop.get(i), nxt = loop.get((i + 1) % n);
|
||||
int cross = (cur[0] - prev[0]) * (nxt[1] - cur[1]) - (cur[1] - prev[1]) * (nxt[0] - cur[0]);
|
||||
if (cross != 0) kept.add(cur);
|
||||
}
|
||||
List<Point> out = new ArrayList<>();
|
||||
for (int[] p : kept) out.add(new Point(p[0] * 16.0, p[1] * 16.0));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the map shows, independent of any map software. Built by {@link MapModel}. */
|
||||
public record MapData(List<ClaimRegion> claims, List<Pin> pins, List<OnlinePlayer> players) {
|
||||
public static MapData empty() { return new MapData(List.of(), List.of(), List.of()); }
|
||||
|
||||
/** One connected area of a guild's land. {@code color} is 0xRRGGBB. */
|
||||
public record ClaimRegion(String id, String guildId, String name, String tag, String icon, int color, String dimension,
|
||||
ChunkRegions.Ring outer, List<ChunkRegions.Ring> holes, int chunks, double labelX, double labelZ, String detail) {}
|
||||
|
||||
/** kind: spawn, warp, home, guild_home, market or shop. {@code detail} is HTML that is already escaped. */
|
||||
public record Pin(String id, String kind, String label, String dimension, double x, double y, double z, String detail) {}
|
||||
|
||||
public record OnlinePlayer(String uuid, String name, long level, String title, long serverLevel, String guild, String guildTag,
|
||||
String guildRole, String rank, long playtimeSecs) {}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.Pos;
|
||||
import net.scopenet.core.ShopPoints;
|
||||
import net.scopenet.integration.ClaimIndex;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Builds {@link MapData} from what the server already knows: claims, warps, spawn, shop points, listings and players. */
|
||||
public final class MapModel {
|
||||
private MapModel() {}
|
||||
|
||||
/** Everything the map is built from. All of it must be safe to read from another thread. */
|
||||
public record Sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Map<String, Pos> warps, Collection<Pos> spawns,
|
||||
Map<UUID, Map<String, Pos>> homes, Map<String, NamedPos> guildHomes, Collection<ShopPoints.Shop> shops,
|
||||
JsonArray market, List<MapData.OnlinePlayer> players, String panelBase, boolean showHomes,
|
||||
Map<UUID, String> homeOwners) {}
|
||||
|
||||
public record NamedPos(String label, Pos pos) {}
|
||||
|
||||
public static String escape(String text) {
|
||||
if (text == null) return "";
|
||||
StringBuilder out = new StringBuilder(text.length() + 8);
|
||||
for (char c : text.toCharArray()) {
|
||||
switch (c) {
|
||||
case '&' -> out.append("&");
|
||||
case '<' -> out.append("<");
|
||||
case '>' -> out.append(">");
|
||||
case '"' -> out.append(""");
|
||||
case '\'' -> out.append("'");
|
||||
default -> { if (c >= 32 || c == '\n') out.append(c); }
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** A stable, pleasant colour per guild: hue from the id, fixed saturation and lightness. */
|
||||
public static int guildColor(String guildId) {
|
||||
int h = guildId.hashCode();
|
||||
double hue = Math.floorMod(h, 360);
|
||||
return hsl(hue, 0.65, 0.55);
|
||||
}
|
||||
|
||||
static int hsl(double h, double s, double l) {
|
||||
double c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c / 2;
|
||||
double r, g, b;
|
||||
if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; }
|
||||
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 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";
|
||||
if (words.contains("end")) return "minecraft:the_end";
|
||||
return "minecraft:overworld";
|
||||
}
|
||||
|
||||
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
|
||||
public static String iconAddress(String icon, String panelBase) {
|
||||
if (icon == null || icon.isBlank()) return "";
|
||||
String i = icon.trim();
|
||||
if (i.startsWith("https://") || i.startsWith("http://")) return i;
|
||||
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
|
||||
return "";
|
||||
}
|
||||
|
||||
public static MapData build(Sources s) {
|
||||
List<MapData.ClaimRegion> claims = new ArrayList<>();
|
||||
s.claims().forEach((dimension, guilds) -> {
|
||||
for (ClaimIndex.GuildClaims g : guilds) {
|
||||
int n = 0;
|
||||
for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) {
|
||||
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
|
||||
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
|
||||
claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()),
|
||||
guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
List<MapData.Pin> pins = new ArrayList<>();
|
||||
int i = 0;
|
||||
for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
|
||||
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
|
||||
s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(),
|
||||
home.pos().x(), home.pos().y(), home.pos().z(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
|
||||
if (s.showHomes()) {
|
||||
s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> {
|
||||
String who = s.homeOwners().getOrDefault(owner, "a player");
|
||||
pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
|
||||
}));
|
||||
}
|
||||
String marketDetail = marketSummary(s.market());
|
||||
for (ShopPoints.Shop shop : s.shops()) {
|
||||
boolean market = shop.kind().equals("market");
|
||||
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
|
||||
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
|
||||
Pos p = shop.pos();
|
||||
pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail));
|
||||
}
|
||||
pins.sort(Comparator.comparing(MapData.Pin::id));
|
||||
return new MapData(claims, pins, s.players());
|
||||
}
|
||||
|
||||
/** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */
|
||||
static String marketSummary(JsonArray listings) {
|
||||
if (listings == null || listings.size() == 0) return "<br><em>No listings right now.</em>";
|
||||
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
|
||||
int shown = 0;
|
||||
for (JsonElement e : listings) {
|
||||
if (shown++ >= 5) break;
|
||||
JsonObject l = e.getAsJsonObject();
|
||||
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name");
|
||||
b.append("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
|
||||
.append(" · ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0))
|
||||
.append(" <span>by ").append(escape(guild)).append("</span></li>");
|
||||
}
|
||||
return b.append("</ul>").toString();
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String key) { return o.has(key) && o.get(key).isJsonPrimitive() ? o.get(key).getAsString() : ""; }
|
||||
|
||||
/** The file the map page reads to describe players who are online (public facts only: never balances). */
|
||||
public static JsonObject playersJson(String server, List<MapData.OnlinePlayer> players, long now) {
|
||||
JsonObject root = new JsonObject();
|
||||
root.addProperty("updated", now);
|
||||
root.addProperty("server", server);
|
||||
JsonObject byId = new JsonObject();
|
||||
for (MapData.OnlinePlayer p : players) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("level", p.level());
|
||||
o.addProperty("title", p.title());
|
||||
o.addProperty("serverLevel", p.serverLevel());
|
||||
o.addProperty("guild", p.guild());
|
||||
o.addProperty("guildTag", p.guildTag());
|
||||
o.addProperty("guildRole", p.guildRole());
|
||||
o.addProperty("rank", p.rank());
|
||||
o.addProperty("playtime", p.playtimeSecs());
|
||||
byId.add(p.uuid(), o);
|
||||
}
|
||||
root.add("players", byId);
|
||||
return root;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.*;
|
||||
import net.scopenet.integration.ClaimIndex;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
public static final long MARKET_REFRESH_MS = 60_000;
|
||||
|
||||
private final Env env;
|
||||
private final ClaimIndex claims;
|
||||
private final MapSource places;
|
||||
private final PlayerCache cache;
|
||||
private final String panelBase;
|
||||
private final boolean showHomes;
|
||||
private final String serverName;
|
||||
private volatile JsonArray market = new JsonArray();
|
||||
private volatile long marketAt = Long.MIN_VALUE / 2;
|
||||
private volatile boolean fetching;
|
||||
|
||||
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
|
||||
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
|
||||
}
|
||||
|
||||
public String serverName() { return serverName; }
|
||||
|
||||
/** Server thread, about once a second. */
|
||||
public void tick() {
|
||||
long now = env.clock.getAsLong();
|
||||
if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return;
|
||||
fetching = true;
|
||||
marketAt = now;
|
||||
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
|
||||
market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
fetching = false;
|
||||
}, e -> fetching = false);
|
||||
}
|
||||
|
||||
/** Safe from any thread: reads only copies and concurrent collections. */
|
||||
public MapData snapshot() {
|
||||
List<MapData.OnlinePlayer> players = onlinePlayers();
|
||||
Map<UUID, String> names = new HashMap<>();
|
||||
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
|
||||
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
|
||||
Map<String, String> labels = new HashMap<>();
|
||||
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
|
||||
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
|
||||
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
|
||||
MapModel.Sources sources = new MapModel.Sources(
|
||||
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
|
||||
env.features.essentials() ? places.warps() : Map.of(),
|
||||
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
|
||||
market, players, panelBase, showHomes, names);
|
||||
return MapModel.build(sources);
|
||||
}
|
||||
|
||||
private List<MapData.OnlinePlayer> onlinePlayers() {
|
||||
List<MapData.OnlinePlayer> out = new ArrayList<>();
|
||||
for (CorePlayer p : env.platform.online()) {
|
||||
JsonObject i = cache.info(p.uuid());
|
||||
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank");
|
||||
out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""),
|
||||
(long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""),
|
||||
PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import net.scopenet.core.Pos;
|
||||
import net.scopenet.core.ShopPoints;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
|
||||
public interface MapSource {
|
||||
Map<String, Pos> warps();
|
||||
Map<UUID, Map<String, Pos>> homes();
|
||||
/** Guild homes by guild id. */
|
||||
Map<String, Pos> guildHomes();
|
||||
Collection<ShopPoints.Shop> shops();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.*;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** All callers (including asynchronous chat events) share one short lock. */
|
||||
public final class Activity {
|
||||
private record Player(String name, long since) {}
|
||||
private final Map<UUID, Player> online = new LinkedHashMap<>();
|
||||
private final Map<UUID, JsonObject> stats = new LinkedHashMap<>();
|
||||
private final List<JsonObject> events = new ArrayList<>();
|
||||
private final Map<String, JsonObject> actions = new LinkedHashMap<>();
|
||||
private long dropped;
|
||||
private final LongSupplier clock;
|
||||
private static final int MAX_PLAYERS = 5000;
|
||||
private static final int MAX_EVENTS = 500;
|
||||
|
||||
public Activity() { this(System::nanoTime); }
|
||||
Activity(LongSupplier clock) { this.clock = clock; }
|
||||
|
||||
public synchronized void join(UUID id, String name) {
|
||||
if (online.containsKey(id)) return;
|
||||
if (online.size() >= MAX_PLAYERS) return;
|
||||
online.put(id, new Player(name, clock.getAsLong()));
|
||||
add(id, name, "joins", 1);
|
||||
event(id, name, "join", null);
|
||||
}
|
||||
|
||||
public synchronized void leave(UUID id, String name) {
|
||||
Player player = online.remove(id);
|
||||
if (player == null) return;
|
||||
add(id, name, "playtime_secs", Math.max(0, (clock.getAsLong() - player.since()) / 1_000_000_000L));
|
||||
event(id, name, "leave", null);
|
||||
}
|
||||
|
||||
public synchronized void add(UUID id, String name, String key, long count) {
|
||||
if (count <= 0 || (!stats.containsKey(id) && stats.size() >= MAX_PLAYERS)) return;
|
||||
JsonObject row = stats.computeIfAbsent(id, unused -> identity(id, name));
|
||||
row.addProperty(key, Math.min(1_000_000L, (row.has(key) ? row.get(key).getAsLong() : 0) + count));
|
||||
}
|
||||
|
||||
public synchronized void event(UUID id, String name, String kind, String detail) {
|
||||
if (events.size() >= 10_000) { dropped++; return; }
|
||||
JsonObject event = identity(id, name);
|
||||
event.addProperty("kind", kind);
|
||||
if (detail != null) event.addProperty("detail", detail.substring(0, Math.min(256, detail.length())));
|
||||
event.addProperty("at", System.currentTimeMillis());
|
||||
events.add(event);
|
||||
}
|
||||
|
||||
/** Repeated vanilla actions are aggregated within each reporting interval. */
|
||||
public synchronized void action(UUID id, String name, String action, long count) {
|
||||
String key = id + ":" + action;
|
||||
if (count <= 0) return;
|
||||
if (!actions.containsKey(key) && actions.size() >= 10_000) { dropped++; return; }
|
||||
JsonObject row = actions.computeIfAbsent(key, unused -> {
|
||||
JsonObject value = identity(id, name);
|
||||
value.addProperty("kind", "action");
|
||||
value.addProperty("action", action);
|
||||
value.addProperty("count", 0);
|
||||
value.addProperty("at", System.currentTimeMillis());
|
||||
return value;
|
||||
});
|
||||
row.addProperty("count", row.get("count").getAsLong() + count);
|
||||
}
|
||||
|
||||
public synchronized JsonObject drain(double tps) {
|
||||
long now = clock.getAsLong();
|
||||
JsonArray players = new JsonArray();
|
||||
online.replaceAll((id, player) -> {
|
||||
long seconds = Math.max(0, (now - player.since()) / 1_000_000_000L);
|
||||
add(id, player.name(), "playtime_secs", seconds);
|
||||
players.add(identity(id, player.name()));
|
||||
return new Player(player.name(), player.since() + seconds * 1_000_000_000L);
|
||||
});
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("batch_id", UUID.randomUUID().toString());
|
||||
payload.addProperty("tps", Double.isFinite(tps) ? Math.max(0, Math.min(20, tps)) : 20);
|
||||
payload.add("online", players);
|
||||
JsonArray rows = new JsonArray();
|
||||
stats.values().forEach(rows::add);
|
||||
payload.add("stats", rows);
|
||||
JsonArray activity = new JsonArray();
|
||||
int eventCount = Math.min(events.size(), MAX_EVENTS - 1);
|
||||
events.subList(0, eventCount).forEach(activity::add);
|
||||
events.subList(0, eventCount).clear();
|
||||
var iterator = actions.values().iterator();
|
||||
while (iterator.hasNext() && activity.size() < MAX_EVENTS - 1) {
|
||||
JsonObject action = iterator.next();
|
||||
action.addProperty("detail", action.remove("action").getAsString() + " +" + action.remove("count").getAsLong());
|
||||
activity.add(action);
|
||||
iterator.remove();
|
||||
}
|
||||
if (dropped > 0) {
|
||||
JsonObject gap = new JsonObject();
|
||||
gap.addProperty("kind", "telemetry_gap");
|
||||
gap.addProperty("detail", dropped + " events exceeded the local queue capacity");
|
||||
activity.add(gap);
|
||||
dropped = 0;
|
||||
}
|
||||
payload.add("events", activity);
|
||||
stats.clear();
|
||||
return payload;
|
||||
}
|
||||
|
||||
public synchronized void leaveAll() {
|
||||
new LinkedHashMap<>(online).forEach((id, player) -> leave(id, player.name()));
|
||||
}
|
||||
|
||||
private static JsonObject identity(UUID id, String name) {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty("uuid", id.toString());
|
||||
object.addProperty("name", name);
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
public record ChunkCheckResult(
|
||||
boolean claimed,
|
||||
boolean allowed,
|
||||
String guildName,
|
||||
String guildTag
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Every claimed chunk on this server, and who may build in each, held in
|
||||
* memory. Block events ask this instead of the panel, so protection costs a
|
||||
* couple of hash lookups and keeps working if the panel is briefly away.
|
||||
* The panel is polled for a new copy only when its revision changes.
|
||||
*/
|
||||
public final class ClaimIndex {
|
||||
private record Guild(String id, String name, String tag, String icon) {}
|
||||
|
||||
private record Snapshot(String revision, Map<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {}
|
||||
|
||||
private static final ChunkCheckResult WILDERNESS = new ChunkCheckResult(false, true, null, null);
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
static long key(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) | (chunkZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
/** False until the first copy arrives from the panel. */
|
||||
public boolean loaded() { return snapshot != null; }
|
||||
|
||||
public String revision() {
|
||||
Snapshot s = snapshot;
|
||||
return s == null ? null : s.revision();
|
||||
}
|
||||
|
||||
public int claimCount() {
|
||||
Snapshot s = snapshot;
|
||||
return s == null ? 0 : s.total();
|
||||
}
|
||||
|
||||
/** Is this chunk claimed by any guild? Used for environmental changes (fire, pistons, explosions). */
|
||||
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
|
||||
Snapshot s = snapshot;
|
||||
if (s == null) return false;
|
||||
Map<Long, Guild> dim = s.claims().get(dimension);
|
||||
return dim != null && dim.containsKey(key(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
/** May {@code player} build here? Unclaimed land always answers yes. */
|
||||
public ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player) {
|
||||
Snapshot s = snapshot;
|
||||
if (s == null) return WILDERNESS;
|
||||
Map<Long, Guild> dim = s.claims().get(dimension);
|
||||
Guild guild = dim == null ? null : dim.get(key(chunkX, chunkZ));
|
||||
if (guild == null) return WILDERNESS;
|
||||
Set<UUID> members = s.members().get(guild.id());
|
||||
return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag());
|
||||
}
|
||||
|
||||
/** One guild's claimed chunks in one dimension, for drawing on a map. */
|
||||
public record GuildClaims(String id, String name, String tag, String icon, Set<Long> chunks) {}
|
||||
|
||||
public static int chunkX(long key) { return (int) (key >> 32); }
|
||||
public static int chunkZ(long key) { return (int) key; }
|
||||
|
||||
/** Every claim grouped by dimension and guild (a copy; safe to use from any thread). */
|
||||
public Map<String, java.util.List<GuildClaims>> byDimension() {
|
||||
Snapshot s = snapshot;
|
||||
Map<String, java.util.List<GuildClaims>> out = new HashMap<>();
|
||||
if (s == null) return out;
|
||||
s.claims().forEach((dimension, chunks) -> {
|
||||
Map<String, GuildClaims> perGuild = new java.util.LinkedHashMap<>();
|
||||
chunks.forEach((key, guild) -> perGuild.computeIfAbsent(guild.id(),
|
||||
id -> new GuildClaims(guild.id(), guild.name(), guild.tag(), guild.icon(), new HashSet<>())).chunks().add(key));
|
||||
out.put(dimension, new java.util.ArrayList<>(perGuild.values()));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */
|
||||
public boolean apply(JsonObject response) {
|
||||
if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false;
|
||||
String revision = response.has("revision") ? response.get("revision").getAsString() : "";
|
||||
JsonArray guildList = response.getAsJsonArray("guilds");
|
||||
Guild[] guilds = new Guild[guildList.size()];
|
||||
for (int i = 0; i < guilds.length; i++) {
|
||||
JsonObject g = guildList.get(i).getAsJsonObject();
|
||||
guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString(),
|
||||
g.has("icon_url") && g.get("icon_url").isJsonPrimitive() ? g.get("icon_url").getAsString() : "");
|
||||
}
|
||||
Map<String, Map<Long, Guild>> claims = new HashMap<>();
|
||||
JsonArray claimList = response.getAsJsonArray("claims");
|
||||
for (JsonElement element : claimList) {
|
||||
JsonArray c = element.getAsJsonArray();
|
||||
int guild = c.get(3).getAsInt();
|
||||
if (guild < 0 || guild >= guilds.length) continue;
|
||||
claims.computeIfAbsent(c.get(0).getAsString(), d -> new HashMap<>()).put(key(c.get(1).getAsInt(), c.get(2).getAsInt()), guilds[guild]);
|
||||
}
|
||||
Map<String, Set<UUID>> members = new HashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : response.getAsJsonObject("members").entrySet()) {
|
||||
Set<UUID> set = new HashSet<>();
|
||||
for (JsonElement uuid : entry.getValue().getAsJsonArray()) {
|
||||
try { set.add(UUID.fromString(uuid.getAsString())); } catch (IllegalArgumentException ignored) { /* skip malformed */ }
|
||||
}
|
||||
members.put(entry.getKey(), set);
|
||||
}
|
||||
snapshot = new Snapshot(revision, claims, members, claimList.size());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.*;
|
||||
|
||||
public final class Integration implements AutoCloseable {
|
||||
public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly.";
|
||||
public final Activity activity = new Activity();
|
||||
private volatile Settings settings;
|
||||
private final PanelClient client;
|
||||
private final JsonObject hello;
|
||||
private final Consumer<String> log;
|
||||
private final BiConsumer<UUID, String> kick;
|
||||
private final ExecutorService syncWorker = Executors.newSingleThreadExecutor(r -> daemon(r, "scopenet-sync"));
|
||||
private final ExecutorService loginWorker = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
|
||||
private final AtomicBoolean syncing = new AtomicBoolean();
|
||||
private volatile boolean closed;
|
||||
private volatile Consumer<JsonArray> notificationHandler;
|
||||
private volatile net.scopenet.worldmap.WorldMapSync map;
|
||||
private volatile java.util.function.Supplier<String> mapOverlay;
|
||||
private boolean registered;
|
||||
private JsonObject pending;
|
||||
private long nextSync;
|
||||
private long ticks;
|
||||
private long sampleStart = System.nanoTime();
|
||||
|
||||
public Integration(Settings settings, String software, String mcVersion, String version,
|
||||
boolean onlineMode, int maxPlayers, Consumer<String> log, BiConsumer<UUID, String> kick) {
|
||||
if (!onlineMode) throw new IllegalArgumentException("SCOPENET requires online-mode=true and authlib-injector");
|
||||
this.settings = settings;
|
||||
this.client = new PanelClient(settings);
|
||||
this.log = log;
|
||||
this.kick = kick;
|
||||
hello = new JsonObject();
|
||||
hello.addProperty("software", software);
|
||||
hello.addProperty("mc_version", mcVersion);
|
||||
hello.addProperty("plugin_version", version);
|
||||
hello.addProperty("online_mode", onlineMode);
|
||||
hello.addProperty("max_players", maxPlayers);
|
||||
}
|
||||
|
||||
/** Null means allowed. Errors, missing settings and overload always deny. */
|
||||
public CompletableFuture<String> login(UUID id, String name, String ip) {
|
||||
if (closed) return CompletableFuture.completedFuture(UNAVAILABLE);
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("uuid", id.toString());
|
||||
request.addProperty("name", name);
|
||||
if (ip != null) request.addProperty("ip", ip);
|
||||
try { return PanelClient.verdict(client.post("login", request)); }
|
||||
catch (Exception e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
}, loginWorker).completeOnTimeout(UNAVAILABLE, 6, TimeUnit.SECONDS);
|
||||
} catch (RejectedExecutionException e) {
|
||||
return CompletableFuture.completedFuture(UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called on the server thread. Network and JSON work runs on the worker. */
|
||||
public void tick() {
|
||||
if (closed) return;
|
||||
ticks++;
|
||||
long now = System.nanoTime();
|
||||
if (now < nextSync || !syncing.compareAndSet(false, true)) return;
|
||||
double tps = Math.min(20, ticks * 1_000_000_000.0 / Math.max(1, now - sampleStart));
|
||||
ticks = 0;
|
||||
sampleStart = now;
|
||||
nextSync = now + TimeUnit.SECONDS.toNanos(30);
|
||||
syncWorker.execute(() -> {
|
||||
try { sync(tps); }
|
||||
catch (Exception e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
String msg = e.getMessage() != null && !e.getMessage().isBlank()
|
||||
? e.getClass().getSimpleName() + ": " + e.getMessage()
|
||||
: e.getClass().getSimpleName();
|
||||
log.accept("SCOPENET sync failed to connect to panel at " + settings.panel() + "; retained batch will be retried (" + msg + ")");
|
||||
} finally { syncing.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
private void sync(double tps) throws Exception {
|
||||
if (!registered) {
|
||||
JsonObject response = client.post("hello", hello);
|
||||
if (!response.has("server_id")) throw new IllegalStateException("Invalid hello response");
|
||||
registered = true;
|
||||
}
|
||||
if (pending == null) {
|
||||
pending = activity.drain(tps);
|
||||
JsonObject features = new JsonObject();
|
||||
features.addProperty("leveling_enabled", settings.levelingEnabled());
|
||||
features.addProperty("global_xp_multiplier", settings.globalXpMultiplier());
|
||||
features.addProperty("server_xp_multiplier", settings.serverXpMultiplier());
|
||||
features.addProperty("quests_enabled", settings.questsEnabled());
|
||||
features.addProperty("achievements_enabled", settings.achievementsEnabled());
|
||||
features.addProperty("guilds_enabled", settings.guildsEnabled());
|
||||
features.addProperty("land_claiming_enabled", settings.landClaimingEnabled());
|
||||
features.addProperty("social_enabled", settings.socialEnabled());
|
||||
pending.add("features", features);
|
||||
}
|
||||
JsonObject response = client.post("sync", pending);
|
||||
if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick")
|
||||
|| !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response");
|
||||
pending = null;
|
||||
Consumer<JsonArray> handler = notificationHandler;
|
||||
if (handler != null && response.has("notifications") && response.get("notifications").isJsonArray()
|
||||
&& response.getAsJsonArray("notifications").size() > 0) {
|
||||
try { handler.accept(response.getAsJsonArray("notifications")); }
|
||||
catch (RuntimeException e) { log.accept("SCOPENET could not process panel events: " + e.getMessage()); }
|
||||
}
|
||||
for (JsonElement element : response.getAsJsonArray("kick")) {
|
||||
try {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
String message = entry.has("message") && !entry.get("message").isJsonNull()
|
||||
? entry.get("message").getAsString() : "Your server access was revoked.";
|
||||
kick.accept(UUID.fromString(entry.get("uuid").getAsString()), message);
|
||||
} catch (RuntimeException e) { log.accept("SCOPENET ignored a malformed kick entry"); }
|
||||
}
|
||||
}
|
||||
|
||||
public void reload(Settings next) {
|
||||
client.updateSettings(next);
|
||||
settings = next;
|
||||
}
|
||||
|
||||
public Settings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public PanelClient client() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive events the panel queued for this server (level-ups, achievements,
|
||||
* guild changes) with each sync. Called on the sync worker thread.
|
||||
*/
|
||||
public void onNotifications(Consumer<JsonArray> handler) {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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) {
|
||||
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
||||
}
|
||||
|
||||
/** Environmental protection: is this chunk claimed by anyone? */
|
||||
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
|
||||
return client.isClaimed(dimension, chunkX, chunkZ);
|
||||
}
|
||||
|
||||
/** Begin keeping the local claim index in sync with the panel. */
|
||||
public void startClaimSync() {
|
||||
client.startClaimSync(log);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.close();
|
||||
client.close();
|
||||
loginWorker.shutdownNow();
|
||||
activity.leaveAll();
|
||||
Future<?> flush = syncWorker.submit(() -> {
|
||||
try {
|
||||
// A retained retry may contain an earlier online snapshot.
|
||||
sync(20);
|
||||
sync(20);
|
||||
} catch (Exception e) { log.accept("SCOPENET final sync failed; unsent activity may be lost"); }
|
||||
});
|
||||
try { flush.get(12, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
catch (ExecutionException | TimeoutException e) { log.accept("SCOPENET final sync did not finish"); }
|
||||
finally { syncWorker.shutdownNow(); }
|
||||
}
|
||||
|
||||
private static Thread daemon(Runnable task, String name) {
|
||||
Thread thread = new Thread(task, name);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A write-ahead queue for panel requests that must not be lost or repeated,
|
||||
* like the money moved by Vault plugins. Each request gets an operation id and
|
||||
* is saved to disk before it is sent; it is retried until the panel answers
|
||||
* (the panel applies an operation id once), and only then forgotten. A panel
|
||||
* outage or a server crash therefore never drops or duplicates a payment.
|
||||
*/
|
||||
public final class OperationLedger implements AutoCloseable {
|
||||
private record Pending(String endpoint, JsonObject payload) {}
|
||||
|
||||
private final Path file;
|
||||
private final PanelClient client;
|
||||
private final Consumer<String> log;
|
||||
private final BiConsumer<String, JsonObject> done;
|
||||
private final Map<String, Pending> pending = new LinkedHashMap<>();
|
||||
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "scopenet-ledger");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private volatile boolean closed;
|
||||
|
||||
/**
|
||||
* @param done called with (operation id, response) after the panel accepted an operation, or with
|
||||
* (operation id, {"error": "..."}) after it permanently refused it
|
||||
*/
|
||||
public OperationLedger(Path file, PanelClient client, Consumer<String> log, BiConsumer<String, JsonObject> done) {
|
||||
this.file = file;
|
||||
this.client = client;
|
||||
this.log = log;
|
||||
this.done = done;
|
||||
load();
|
||||
worker.scheduleWithFixedDelay(this::drain, 1, 2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public synchronized int size() { return pending.size(); }
|
||||
|
||||
/** Save the operation and start sending it. Returns its operation id. */
|
||||
public String submit(String endpoint, JsonObject payload) throws IOException {
|
||||
String id = UUID.randomUUID().toString();
|
||||
payload.addProperty("operation_id", id);
|
||||
synchronized (this) {
|
||||
pending.put(id, new Pending(endpoint, payload));
|
||||
try { save(); } catch (IOException e) { pending.remove(id); throw e; }
|
||||
}
|
||||
try { worker.execute(this::drain); } catch (RuntimeException ignored) { /* closing */ }
|
||||
return id;
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
if (closed) return;
|
||||
Map<String, Pending> batch;
|
||||
synchronized (this) { batch = new LinkedHashMap<>(pending); }
|
||||
for (Map.Entry<String, Pending> entry : batch.entrySet()) {
|
||||
if (closed) return;
|
||||
String id = entry.getKey();
|
||||
JsonObject result;
|
||||
try {
|
||||
result = client.post(entry.getValue().endpoint(), entry.getValue().payload().deepCopy());
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status >= 400 && e.status < 500 && e.status != 408 && e.status != 429) {
|
||||
// The panel will never accept this one; report it and move on.
|
||||
JsonObject error = new JsonObject();
|
||||
error.addProperty("error", e.getMessage());
|
||||
finish(id, error);
|
||||
continue;
|
||||
}
|
||||
return; // panel trouble: try again later, keeping the order
|
||||
} catch (IOException e) {
|
||||
return; // unreachable: try again later
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
finish(id, result);
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(String id, JsonObject result) {
|
||||
synchronized (this) {
|
||||
pending.remove(id);
|
||||
try { save(); } catch (IOException e) { log.accept("SCOPENET could not update " + file.getFileName() + ": " + e.getMessage()); }
|
||||
}
|
||||
try { done.accept(id, result); } catch (RuntimeException e) { log.accept("SCOPENET operation callback failed: " + e.getMessage()); }
|
||||
}
|
||||
|
||||
private void load() {
|
||||
try {
|
||||
if (!Files.isRegularFile(file)) return;
|
||||
JsonObject root = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
for (Map.Entry<String, com.google.gson.JsonElement> e : root.entrySet()) {
|
||||
JsonObject op = e.getValue().getAsJsonObject();
|
||||
pending.put(e.getKey(), new Pending(op.get("endpoint").getAsString(), op.getAsJsonObject("payload")));
|
||||
}
|
||||
if (!pending.isEmpty()) log.accept("SCOPENET is resending " + pending.size() + " saved transaction(s)");
|
||||
} catch (Exception e) {
|
||||
log.accept("SCOPENET could not read " + file.getFileName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void save() throws IOException {
|
||||
JsonObject root = new JsonObject();
|
||||
for (Map.Entry<String, Pending> e : pending.entrySet()) {
|
||||
JsonObject op = new JsonObject();
|
||||
op.addProperty("endpoint", e.getValue().endpoint());
|
||||
op.add("payload", e.getValue().payload());
|
||||
root.add(e.getKey(), op);
|
||||
}
|
||||
Path parent = file.toAbsolutePath().getParent();
|
||||
if (parent != null) Files.createDirectories(parent);
|
||||
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.writeString(temp, root.toString(), StandardCharsets.UTF_8);
|
||||
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class PanelClient {
|
||||
private volatile Settings settings;
|
||||
private final ClaimIndex claimIndex = new ClaimIndex();
|
||||
private final java.util.concurrent.ScheduledExecutorService claimSync = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread t = new Thread(task, "scopenet-claims"); t.setDaemon(true); return t;
|
||||
});
|
||||
private final java.util.concurrent.atomic.AtomicBoolean claimRefreshQueued = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private volatile boolean claimSyncStarted;
|
||||
private volatile long claimFailureLogged;
|
||||
private volatile java.util.function.Consumer<String> claimLog = m -> {};
|
||||
|
||||
public void updateSettings(Settings next) { settings = next; requestClaimRefresh(); }
|
||||
public void close() { claimSync.shutdownNow(); }
|
||||
public ClaimIndex claims() { return claimIndex; }
|
||||
|
||||
/**
|
||||
* Keep the local claim index fresh: load it now (so protection is on from
|
||||
* the first tick) and then check the panel's revision every few seconds.
|
||||
*/
|
||||
public void startClaimSync(java.util.function.Consumer<String> log) {
|
||||
if (claimSyncStarted) return;
|
||||
claimSyncStarted = true;
|
||||
claimLog = log;
|
||||
claimSync.scheduleWithFixedDelay(this::refreshClaimsQuietly, 0, 5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** Pull a fresh index soon, e.g. right after this server changed a claim. */
|
||||
public void requestClaimRefresh() {
|
||||
if (!claimSyncStarted || !claimRefreshQueued.compareAndSet(false, true)) return;
|
||||
try { claimSync.execute(() -> { claimRefreshQueued.set(false); refreshClaimsQuietly(); }); }
|
||||
catch (java.util.concurrent.RejectedExecutionException e) { claimRefreshQueued.set(false); }
|
||||
}
|
||||
|
||||
/** One synchronous refresh. Returns true when the index changed. */
|
||||
public boolean refreshClaims() throws IOException, InterruptedException {
|
||||
JsonObject body = new JsonObject();
|
||||
String revision = claimIndex.revision();
|
||||
if (revision != null) body.addProperty("revision", revision);
|
||||
return claimIndex.apply(post("guilds/claim-index", body));
|
||||
}
|
||||
|
||||
private void refreshClaimsQuietly() {
|
||||
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return;
|
||||
try {
|
||||
boolean first = !claimIndex.loaded();
|
||||
boolean changed = refreshClaims();
|
||||
if (first && changed) claimLog.accept("SCOPENET claim protection loaded (" + claimIndex.claimCount() + " claimed chunks)");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
// Keep enforcing the last copy we have; just don't spam the log.
|
||||
long now = System.nanoTime();
|
||||
if (now - claimFailureLogged > java.util.concurrent.TimeUnit.MINUTES.toNanos(5)) {
|
||||
claimFailureLogged = now;
|
||||
claimLog.accept("SCOPENET couldn't refresh claims from the panel (" + e.getMessage() + "); using the last known copy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class HttpFailure extends IOException {
|
||||
public final int status;
|
||||
public HttpFailure(int status, String message) { super(message); this.status = status; }
|
||||
}
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
|
||||
public PanelClient(Settings settings) { this.settings = settings; }
|
||||
|
||||
public JsonElement postElement(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Authorization", "Bearer " + settings.token())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
String msg = "HTTP " + response.statusCode();
|
||||
try {
|
||||
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
if (err.has("message")) msg = err.get("message").getAsString();
|
||||
else if (err.has("error")) msg = err.get("error").getAsString();
|
||||
} catch (Exception ignored) {}
|
||||
throw new HttpFailure(response.statusCode(), msg);
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(response.body());
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid panel response", e);
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient http() { return client; }
|
||||
public Settings settings() { return settings; }
|
||||
|
||||
private static JsonObject object(HttpResponse<String> response) throws IOException {
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
String msg = "HTTP " + response.statusCode();
|
||||
try {
|
||||
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
if (err.has("message")) msg = err.get("message").getAsString();
|
||||
else if (err.has("error")) msg = err.get("error").getAsString();
|
||||
} catch (Exception ignored) {}
|
||||
throw new HttpFailure(response.statusCode(), msg);
|
||||
}
|
||||
try { return JsonParser.parseString(response.body()).getAsJsonObject(); }
|
||||
catch (RuntimeException e) { throw new IOException("Invalid panel response", e); }
|
||||
}
|
||||
|
||||
/** GET an endpoint under /api/server/v1/ that returns a JSON object. */
|
||||
public JsonObject get(String endpoint) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("Authorization", "Bearer " + settings.token()).GET().build();
|
||||
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
/** POST raw bytes (live map chunk batches and level.dat). */
|
||||
public JsonObject postBytes(String endpoint, byte[] body) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Authorization", "Bearer " + settings.token())
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
|
||||
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||
return postElement(endpoint, payload).getAsJsonObject();
|
||||
}
|
||||
|
||||
public static String verdict(JsonObject response) throws IOException {
|
||||
JsonElement allowed = response.get("allowed");
|
||||
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
|
||||
throw new IOException("Missing login verdict");
|
||||
}
|
||||
if (allowed.getAsBoolean()) return null;
|
||||
JsonElement message = response.get("message");
|
||||
return message != null && message.isJsonPrimitive() && message.getAsJsonPrimitive().isString()
|
||||
? message.getAsString() : "You do not have access to this server.";
|
||||
}
|
||||
|
||||
/** Local lookup; never touches the network. */
|
||||
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
|
||||
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return new ChunkCheckResult(false, true, null, null);
|
||||
return claimIndex.check(dimension, chunkX, chunkZ, uuid);
|
||||
}
|
||||
|
||||
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
|
||||
return settings.guildsEnabled() && settings.landClaimingEnabled() && claimIndex.isClaimed(dimension, chunkX, chunkZ);
|
||||
}
|
||||
|
||||
public JsonObject claimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("dimension", dimension);
|
||||
req.addProperty("chunk_x", chunkX);
|
||||
req.addProperty("chunk_z", chunkZ);
|
||||
JsonObject result = post("guilds/claim", req);
|
||||
requestClaimRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
public JsonObject unclaimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("dimension", dimension);
|
||||
req.addProperty("chunk_x", chunkX);
|
||||
req.addProperty("chunk_z", chunkZ);
|
||||
JsonObject result = post("guilds/unclaim", req);
|
||||
requestClaimRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
public JsonObject getPlayerGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
return post("guilds/player", req);
|
||||
}
|
||||
|
||||
public JsonObject createGuild(java.util.UUID uuid, String username, String name, String tag) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("username", username);
|
||||
req.addProperty("name", name);
|
||||
req.addProperty("tag", tag);
|
||||
JsonObject result = post("guilds/create", req);
|
||||
requestClaimRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
public JsonObject leaveGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
JsonObject result = post("guilds/leave", req);
|
||||
requestClaimRefresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
public double getBalance(java.util.UUID uuid, String username) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("username", username);
|
||||
JsonObject res = post("economy/balance", req);
|
||||
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
|
||||
}
|
||||
|
||||
public double transfer(java.util.UUID fromUuid, String fromName, java.util.UUID toUuid, String toName, double amount, String description) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("from_uuid", fromUuid.toString());
|
||||
req.addProperty("from_name", fromName);
|
||||
req.addProperty("to_uuid", toUuid.toString());
|
||||
req.addProperty("to_name", toName);
|
||||
req.addProperty("amount", amount);
|
||||
req.addProperty("description", description);
|
||||
JsonObject res = post("economy/transfer", req);
|
||||
return res.has("from_balance") ? res.get("from_balance").getAsDouble() : 0.0;
|
||||
}
|
||||
|
||||
public void syncBalance(java.util.UUID uuid, String username, double newBalance) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("username", username);
|
||||
req.addProperty("new_balance", newBalance);
|
||||
post("economy/sync-balance", req);
|
||||
}
|
||||
|
||||
public JsonArray getBaltop() throws IOException, InterruptedException {
|
||||
JsonElement el = postElement("economy/baltop", new JsonObject());
|
||||
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
}
|
||||
|
||||
public JsonObject marketList(java.util.UUID sellerUuid, String sellerName, String itemId, String itemName, int amount, double price) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
||||
req.addProperty("seller_uuid", sellerUuid.toString());
|
||||
req.addProperty("seller_name", sellerName);
|
||||
req.addProperty("item_id", itemId);
|
||||
req.addProperty("item_name", itemName);
|
||||
req.addProperty("amount", amount);
|
||||
req.addProperty("price", price);
|
||||
return post("economy/market/list", req);
|
||||
}
|
||||
|
||||
public JsonObject marketBuy(long listingId, java.util.UUID buyerUuid, String buyerName) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
||||
req.addProperty("listing_id", listingId);
|
||||
req.addProperty("buyer_uuid", buyerUuid.toString());
|
||||
req.addProperty("buyer_name", buyerName);
|
||||
return post("economy/market/buy", req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically add (or subtract, if amount is negative) to a player's balance.
|
||||
* Returns the new balance. This avoids the read-modify-write race in the old
|
||||
* getBalance + syncBalance pattern.
|
||||
*/
|
||||
public double addBalance(java.util.UUID uuid, String username, double amount, String description) throws IOException, InterruptedException {
|
||||
JsonObject req = new JsonObject();
|
||||
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
||||
req.addProperty("uuid", uuid.toString());
|
||||
req.addProperty("username", username);
|
||||
req.addProperty("delta", amount);
|
||||
req.addProperty("description", description);
|
||||
JsonObject res = post("economy/adjust", req);
|
||||
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
|
||||
}
|
||||
|
||||
/** Fetch active player marketplace listings. */
|
||||
public JsonArray getMarketListings() throws IOException, InterruptedException {
|
||||
JsonElement el = postElement("economy/market", new JsonObject());
|
||||
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
public record Settings(
|
||||
URI panel,
|
||||
String token,
|
||||
boolean levelingEnabled,
|
||||
double globalXpMultiplier,
|
||||
double serverXpMultiplier,
|
||||
boolean questsEnabled,
|
||||
boolean achievementsEnabled,
|
||||
boolean guildsEnabled,
|
||||
boolean landClaimingEnabled,
|
||||
boolean socialEnabled,
|
||||
boolean chatPrefixesEnabled,
|
||||
boolean essentialsEnabled,
|
||||
boolean economyEnabled
|
||||
) {
|
||||
public Settings {
|
||||
if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null
|
||||
|| panel.getRawQuery() != null || panel.getRawFragment() != null
|
||||
|| !("https".equals(panel.getScheme()) || "http".equals(panel.getScheme()))) {
|
||||
throw new IllegalArgumentException("panel-url must be an HTTP(S) URL without credentials, query or fragment");
|
||||
}
|
||||
token = token == null ? "" : token.trim();
|
||||
if (!token.matches("sn_[A-Za-z0-9]{40}")) {
|
||||
throw new IllegalArgumentException("Set the server token from the panel's Servers page");
|
||||
}
|
||||
}
|
||||
|
||||
public Settings(URI panel, String token) {
|
||||
this(panel, token, true, 1.0, 1.5, true, true, true, true, true, true, true, true);
|
||||
}
|
||||
|
||||
public Settings(
|
||||
URI panel,
|
||||
String token,
|
||||
boolean levelingEnabled,
|
||||
double globalXpMultiplier,
|
||||
double serverXpMultiplier,
|
||||
boolean questsEnabled,
|
||||
boolean achievementsEnabled,
|
||||
boolean guildsEnabled,
|
||||
boolean landClaimingEnabled,
|
||||
boolean socialEnabled,
|
||||
boolean chatPrefixesEnabled
|
||||
) {
|
||||
this(panel, token, levelingEnabled, globalXpMultiplier, serverXpMultiplier, questsEnabled, achievementsEnabled, guildsEnabled, landClaimingEnabled, socialEnabled, chatPrefixesEnabled, true, true);
|
||||
}
|
||||
|
||||
public static Settings of(String panel, String token) {
|
||||
return of(panel, token, true, 1.0, 1.5, true, true, true, true, true, true, true, true);
|
||||
}
|
||||
|
||||
public static Settings of(
|
||||
String panel,
|
||||
String token,
|
||||
boolean levelingEnabled,
|
||||
double globalXpMultiplier,
|
||||
double serverXpMultiplier,
|
||||
boolean questsEnabled,
|
||||
boolean achievementsEnabled,
|
||||
boolean guildsEnabled,
|
||||
boolean landClaimingEnabled,
|
||||
boolean socialEnabled,
|
||||
boolean chatPrefixesEnabled
|
||||
) {
|
||||
return of(panel, token, levelingEnabled, globalXpMultiplier, serverXpMultiplier, questsEnabled, achievementsEnabled, guildsEnabled, landClaimingEnabled, socialEnabled, chatPrefixesEnabled, true, true);
|
||||
}
|
||||
|
||||
public static Settings of(
|
||||
String panel,
|
||||
String token,
|
||||
boolean levelingEnabled,
|
||||
double globalXpMultiplier,
|
||||
double serverXpMultiplier,
|
||||
boolean questsEnabled,
|
||||
boolean achievementsEnabled,
|
||||
boolean guildsEnabled,
|
||||
boolean landClaimingEnabled,
|
||||
boolean socialEnabled,
|
||||
boolean chatPrefixesEnabled,
|
||||
boolean essentialsEnabled,
|
||||
boolean economyEnabled
|
||||
) {
|
||||
return new Settings(
|
||||
URI.create(panel.trim().replaceAll("/+$", "")),
|
||||
token,
|
||||
levelingEnabled,
|
||||
globalXpMultiplier,
|
||||
serverXpMultiplier,
|
||||
questsEnabled,
|
||||
achievementsEnabled,
|
||||
guildsEnabled,
|
||||
landClaimingEnabled,
|
||||
socialEnabled,
|
||||
chatPrefixesEnabled,
|
||||
essentialsEnabled,
|
||||
economyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
/** 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("map.enabled", properties.getProperty("livemap.enabled", "true")));
|
||||
} catch (IOException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static Settings load(Path path) throws IOException {
|
||||
if (!Files.exists(path)) {
|
||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||
String defaultProps = """
|
||||
# SCOPENET server integration
|
||||
panel-url=https://panel.example.com
|
||||
token=
|
||||
|
||||
# Feature Toggles (set false to disable systems on this server)
|
||||
leveling.enabled=true
|
||||
leveling.global_xp_multiplier=1.0
|
||||
leveling.server_xp_multiplier=1.5
|
||||
quests.enabled=true
|
||||
achievements.enabled=true
|
||||
guilds.enabled=true
|
||||
guilds.land_claiming=true
|
||||
social.enabled=true
|
||||
social.chat_prefixes=true
|
||||
essentials.enabled=true
|
||||
economy.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);
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
properties.load(reader);
|
||||
}
|
||||
return of(
|
||||
properties.getProperty("panel-url", ""),
|
||||
properties.getProperty("token", ""),
|
||||
Boolean.parseBoolean(properties.getProperty("leveling.enabled", "true")),
|
||||
Double.parseDouble(properties.getProperty("leveling.global_xp_multiplier", "1.0")),
|
||||
Double.parseDouble(properties.getProperty("leveling.server_xp_multiplier", "1.5")),
|
||||
Boolean.parseBoolean(properties.getProperty("quests.enabled", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("achievements.enabled", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("guilds.enabled", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("guilds.land_claiming", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("social.enabled", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("social.chat_prefixes", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("essentials.enabled", "true")),
|
||||
Boolean.parseBoolean(properties.getProperty("economy.enabled", "true"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user