46 Commits
Author SHA1 Message Date
Claude 38c9f249e1 Throttle client-link requests per player 2026-09-30 19:38:49 +00:00
Claude 2bcdf26351 Fabric 1.20.1: server features (commands, LuckPerms, Placeholder API, economy API, client link) and client mod (HUD, editor, shop and market windows); Paper client link; docs and examples
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 19:38:18 +00:00
Claude 84c9d46892 Client link protocol, player cache and placeholders in the shared core 2026-09-30 19:30:07 +00:00
Claude f8862c5431 Shared command core: economy, guilds, bank, escrow jobs, tests 2026-09-30 19:28:38 +00:00
Claude 97bdcbb7af Docs: full Vantage 3D map setup guide; fully commented Paper example config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 19:01:38 +00:00
Claude 89f588d2f2 Shared essentials core (homes, warps, back, TPA, RTP) with tests; example configs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:57:24 +00:00
Claude 9dc7f12fb0 Richer player profiles, launcher command guide, integration docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:29:07 +00:00
Claude 745c2178ee Cross-server network: shared economy groups for balances and guild banks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:23:47 +00:00
Claude 485a81e0a4 Discord community: role sync, announcements webhook, launcher invite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:20:33 +00:00
Claude 1e9428945f Admin panel: plugin integrations view and LuckPerms group mapping
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:16:27 +00:00
Claude 9f3ed3fe24 Add SCOPENET developer API and plugin compatibility layer (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 18:14:50 +00:00
Claude c4a222451c Fix guild info balance decoding 2026-09-30 18:06:59 +00:00
Claude badb5fccc0 Panel: plugin integrations (LuckPerms ranks + group mapping, reports), server event queue, developer API endpoints 2026-09-30 18:06:18 +00:00
Claude c256c15d0e Permission nodes for every command/subcommand with wildcard parents 2026-09-30 17:59:24 +00:00
Claude e6c6073eae Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments 2026-09-30 17:57:58 +00:00
Claude 5defda1128 Claim protection from a local index: revision-polled panel endpoint, no per-block network checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:51:05 +00:00
Claude 78da10364f Keep deleted players' names reserved 2026-09-30 17:46:54 +00:00
Claude e344e7144e Fix Friends & Social member search; purge all account data on deletion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:46:10 +00:00
Claude 1a321336b4 Fix achievement icons in launcher, allow opening OAuth URLs, live map docs
- Shared achievement icon catalogue; launcher resolves icon/background/border ids
- Tauri opener capability now allows http(s) URLs (fixes Discord sign-in)
- Rewrite live map docs, admin guide progression section

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:31:24 +00:00
Claude 3ceed4382f Admin Progression page: quest limits, XP curve and rates, per-player XP adjustments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:28:26 +00:00
Claude e3e25da616 Admin progression controls: quest limits/rotation, XP rates, level curve, manual XP adjustments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:25:12 +00:00
Claude f2362def88 Redesign guild treasury and territory toolbar; wallet API returns role and personal balance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:19:43 +00:00
Claude fdc54577fd Live map: plugin/mod chunk+player sync, admin panel Live Map section, launcher transport
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:15:24 +00:00
Claude 8acb4a64e6 Add central live map backend: chunk/player ingest, Vantage sidecar supervisor and proxy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 17:09:31 +00:00
SCOPEDD 004862ea88 Add account connections, guild wallets and admin fixes [skip ci] 2026-09-30 12:25:29 -04:00
SCOPEDD 7076e847ea Release Vantage viewer after failed loads [skip ci] 2026-09-30 11:05:21 -04:00
SCOPEDD 602e7fd0f0 Integrate Vantage live map for guild claims [skip ci] 2026-09-30 11:02:51 -04:00
SCOPEDD 52de8c3031 Build local release artifacts and prepare Compose deployment [skip ci] 2026-09-30 07:22:41 -04:00
SCOPEDD 6deff62a3a Add launcher preferences and modular landing studio [skip ci] 2026-09-30 06:48:03 -04:00
SCOPEDD 454620f21e Fix PR 3 review findings [skip ci] 2026-09-29 23:21:14 -04:00
scoped 590178e41a fix: resolve all remaining audit failures across rust, svelte, and java 2026-09-29 15:51:15 -04:00
scoped bc12a25a01 chore: bump version to 0.4.0, add Gradle 9.6 wrapper, and finalize builds for NSIS, Docker, Fabric, Forge, and Paper 2026-09-29 13:16:34 -04:00
scoped 4be366ff16 fix(launcher): resolve a11y warnings on economy card and member name buttons 2026-09-29 12:16:31 -04:00
scoped 752f0cc43d feat: complete in-game commands, GUIs, admin panel fixes, and launcher enhancements 2026-09-29 12:14:16 -04:00
SCOPEDD a9ff92e1d1 chore(docker): ensure resilient multi-arch detection and explicit ziglang dependency 2026-09-29 03:59:17 -04:00
SCOPEDD b4cef85b1f feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs 2026-09-29 03:24:27 -04:00
SCOPEDD 4b79f69c20 feat(panel): add modular landing page builder, public landing page, and hosted launcher downloads 2026-09-29 02:32:48 -04:00
SCOPEDD 3043f3ab42 Add player stats and server leaderboards to Desktop app and polish UI 2026-09-29 02:23:18 -04:00
SCOPEDD ebd9de1bee Add saved skin and cape profiles wardrobe feature 2026-09-29 02:15:38 -04:00
SCOPEDD c7aa563dac Fix launcher verification session check and socket IP resolution 2026-09-29 02:03:08 -04:00
SCOPEDD 522e3a5717 Allow for logos to be used as instance names 2026-09-29 01:58:53 -04:00
SCOPEDD a1f19e86c6 Complete private authentication, server integrations and activity reporting
Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
2026-09-28 13:31:17 -04:00
Claude c7efd759a8 Panel web: servers, capes, player profiles, auth server settings
- Servers: live status, players and TPS; add a server and get its token
  with copy-paste setup for the plugin/mod and authlib-injector; detail
  page with leaderboard (sortable), online players and activity feed
- Capes: upload, preview, and choose who can wear each one
- Players: click a player for their skin (upload, arm style, reset),
  cape, playtime and per-server stats; disable with a reason
- Settings: Microsoft section replaced by the auth server (public
  address, Yggdrasil URL, start flag, signing key)
- Dashboard: players online now and connected servers
- Avatars come from the panel's own skins

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 09:12:59 +00:00
Claude a50963f9e0 Panel: game server integration API
Game servers (Paper plugin, Fabric/Forge mods) authenticate with a
per-server token and report to /api/server/v1:

- hello: records software/version, returns the auth server URL
- login: allow/deny with a message (account status + reason, access
  all/members/groups, optional "must join through the launcher" check
  matching the player's recent launch IP)
- sync: online players, TPS, stat deltas and events; answers with
  players to kick when their account was disabled meanwhile

Admins create servers (token shown once, stored hashed), regenerate
tokens, and read leaderboards, events and per-player activity. The
dashboard stats include who's online right now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 09:04:11 +00:00
Claude 77a15cab8b Launcher: panel accounts via authlib-injector, skin & cape editor
Microsoft sign-in is gone. Server accounts now receive Yggdrasil tokens
from the panel and launch with authlib-injector pointed at the panel's
auth server, so online-mode servers verify players against it.

- Download authlib-injector from the panel mirror (fallback: official),
  SHA-256 verified and cached, with prefetched metadata
- New Skin & cape settings tab: upload, arm style, reset, cape picker
  with front/back previews
- Avatars render from the panel's head endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 08:58:27 +00:00
Claude 99b45141fc Panel: Yggdrasil auth server (authlib-injector), skins and capes
- Yggdrasil API per the authlib-injector spec: metadata with signing key
  and skin domains, authenticate/refresh/validate/invalidate/signout,
  join/hasJoined, profile lookup, texture upload, and the minecraftservices
  endpoints (chat certificates, publickeys, attributes, blocklist)
- 4096-bit signing key generated once into the data volume; textures and
  chat certificates signed SHA1withRSA (verified with Java's own crypto)
- Skins/capes stored content-addressed after validation and re-encoding;
  cape library with public/group/private visibility; head avatars API
- Launcher login returns a game session; launcher sessions recorded for
  launcher-only servers; authlib-injector download mirror
- Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens,
  sessions, chat keys, game server tables
- Remove Microsoft sign-in from the engine and panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 06:57:07 +00:00
313 changed files with 42636 additions and 825 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ target
.github .github
data data
docs docs
/release-artifacts
launcher/src launcher/src
launcher/src-tauri/icons launcher/src-tauri/icons
launcher/src-tauri/gen launcher/src-tauri/gen
+10 -2
View File
@@ -1,8 +1,16 @@
# 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 PANEL_PORT=8080
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-please ADMIN_PASSWORD=
JWT_SECRET= JWT_SECRET=
CURSEFORGE_API_KEY= CURSEFORGE_API_KEY=
MAX_UPLOAD_MB=2048 MAX_UPLOAD_MB=2048
RUST_LOG=info RUST_LOG=info
PUBLIC_URL=
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
SCOPENET_TRUSTED_PROXIES=
# Live Map: optional static Vantage Linux release to bundle, and memory for the panel.
VANTAGE_URL=
PANEL_MEMORY=256M
+74
View File
@@ -0,0 +1,74 @@
name: Server integrations
on:
push:
branches: ['**']
paths: ['integrations/**', '.github/workflows/integrations.yml']
pull_request:
paths: ['integrations/**', '.github/workflows/integrations.yml']
workflow_dispatch:
workflow_call:
inputs:
version:
type: string
default: '0.4.0'
permissions:
contents: read
jobs:
paper:
name: Paper and protocol tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.10.2'
- run: gradle -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.4.0' }}"
- uses: actions/upload-artifact@v4
with:
name: server-paper
path: integrations/paper/build/libs/scopenet-paper-*.jar
if-no-files-found: error
- uses: actions/upload-artifact@v4
if: failure()
with:
name: integration-test-reports
path: integrations/common/build/reports/tests/
mods:
name: ${{ matrix.loader }} / ${{ matrix.mc.version }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
loader: [fabric, forge]
mc:
- { version: '1.20.1', java: '21', gradle: '8.10.2' }
- { version: '1.21.1', java: '21', gradle: '8.10.2' }
- { version: '26.1.2', java: '25', gradle: '9.6.0' }
- { version: '26.2', java: '25', gradle: '9.6.0' }
- { version: '26.3', java: '25', gradle: '9.6.0' }
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.mc.java }}
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: ${{ matrix.mc.gradle }}
- run: gradle -p integrations -Ploader=${{ matrix.loader }} -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.4.0' }}" :${{ matrix.loader }}:build
- name: Audit Fabric hooks during server bootstrap (no world startup)
if: matrix.loader == 'fabric'
run: gradle -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.4.0' }}" -PverifyMixins=true :fabric:runServer
- uses: actions/upload-artifact@v4
with:
name: server-${{ matrix.loader }}-${{ matrix.mc.version }}
path: integrations/${{ matrix.loader }}/build/libs/scopenet-${{ matrix.loader }}-${{ matrix.mc.version }}-*.jar
if-no-files-found: error
+17 -2
View File
@@ -131,15 +131,26 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
server-integrations:
needs: version
uses: ./.github/workflows/integrations.yml
with:
version: ${{ needs.version.outputs.version }}
release: release:
name: GitHub release name: GitHub release
needs: [version, launcher, panel-image] needs: [version, launcher, panel-image, server-integrations]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: windows-installer name: windows-installer
path: dist path: dist
- uses: actions/download-artifact@v4
with:
pattern: server-*
merge-multiple: true
path: dist
- name: Release notes - name: Release notes
run: | run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
@@ -148,6 +159,8 @@ jobs:
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically. **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):** **Admin panel (Docker):**
\`\`\`bash \`\`\`bash
docker pull ${IMAGE}:${{ needs.version.outputs.version }} docker pull ${IMAGE}:${{ needs.version.outputs.version }}
@@ -162,4 +175,6 @@ jobs:
body_path: notes.md body_path: notes.md
generate_release_notes: true generate_release_notes: true
prerelease: ${{ contains(needs.version.outputs.version, '-') }} prerelease: ${{ contains(needs.version.outputs.version, '-') }}
files: dist/*-setup.exe files: |
dist/*-setup.exe
dist/*.jar
+5
View File
@@ -10,3 +10,8 @@ build/
/panel/server/data /panel/server/data
launcher/src-tauri/gen/schemas launcher/src-tauri/gen/schemas
.DS_Store .DS_Store
/integrations/.gradle/
/integrations/**/.gradle/
/integrations/**/build/
/integrations/**/run/
/integrations/dist/
Generated
+298 -5
View File
@@ -279,6 +279,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"axum-macros", "axum-macros",
"base64 0.22.1",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@@ -298,8 +299,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -478,6 +481,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -698,6 +707,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.2" version = "0.18.2"
@@ -904,6 +919,12 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "dbus" name = "dbus"
version = "0.9.12" version = "0.9.12"
@@ -946,6 +967,17 @@ dependencies = [
"thiserror 2.0.21", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -994,6 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
"subtle", "subtle",
] ]
@@ -1131,6 +1164,22 @@ dependencies = [
"serde", "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]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.11" version = "3.0.11"
@@ -2108,6 +2157,19 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2378,6 +2440,36 @@ name = "lazy_static"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 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]] [[package]]
name = "libappindicator" name = "libappindicator"
@@ -2428,6 +2520,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.25" version = "0.1.25"
@@ -2579,6 +2677,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "muda" name = "muda"
version = "0.20.0" version = "0.20.0"
@@ -2659,6 +2767,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2678,6 +2795,22 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2693,6 +2826,16 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -2700,6 +2843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -3035,6 +3179,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3111,6 +3264,27 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3288,6 +3462,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.42.0" version = "0.42.0"
@@ -3362,6 +3542,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "5.3.0"
@@ -3381,10 +3567,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core 0.6.4", "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]] [[package]]
name = "rand" name = "rand"
version = "0.10.3" version = "0.10.3"
@@ -3406,6 +3602,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
@@ -3415,6 +3621,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"
@@ -3523,6 +3738,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -3618,6 +3834,27 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3652,6 +3889,7 @@ version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3761,9 +3999,10 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "scopenet-core" name = "scopenet-core"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"fastnbt", "fastnbt",
"futures", "futures",
"hex", "hex",
@@ -3772,6 +4011,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing", "tracing",
@@ -3781,7 +4021,7 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-launcher" name = "scopenet-launcher"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@@ -3807,23 +4047,28 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-panel" name = "scopenet-panel"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"futures", "futures",
"hex", "hex",
"image",
"jsonwebtoken", "jsonwebtoken",
"lettre",
"percent-encoding", "percent-encoding",
"rand 0.8.8", "rand 0.8.8",
"reqwest 0.12.28", "reqwest 0.12.28",
"rsa",
"scopenet-core", "scopenet-core",
"scopenet-shared", "scopenet-shared",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -3838,7 +4083,7 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-shared" name = "scopenet-shared"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"md-5", "md-5",
"serde", "serde",
@@ -4149,6 +4394,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4258,6 +4513,16 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.6" version = "0.8.6"
@@ -5011,6 +5276,18 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -5268,6 +5545,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 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]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
+6 -2
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"] members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.4.0"
edition = "2021" edition = "2021"
repository = "https://github.com/scopeddlol/SCOPENET-MC" 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"] } 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"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
futures = "0.3" futures = "0.3"
sha1 = "0.10" sha1 = { version = "0.10", features = ["oid"] }
sha2 = "0.10"
md-5 = "0.10" md-5 = "0.10"
hex = "0.4" hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
@@ -37,3 +38,6 @@ strip = true
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 1 opt-level = 1
# Dependencies rarely need a debugger; skipping their debug info keeps
# target/ several times smaller.
debug = false
+22 -5
View File
@@ -18,11 +18,11 @@ ARG TARGETARCH
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets. # 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 \ RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild && pip install --no-cache-dir --break-system-packages cargo-zigbuild ziglang
RUN case "$TARGETARCH" in \ RUN case "${TARGETARCH:-$(uname -m)}" in \
amd64) echo x86_64-unknown-linux-musl ;; \ amd64|x86_64) echo x86_64-unknown-linux-musl ;; \
arm64) echo aarch64-unknown-linux-musl ;; \ arm64|aarch64) echo aarch64-unknown-linux-musl ;; \
*) echo "unsupported arch $TARGETARCH" >&2; exit 1 ;; \ *) echo "unsupported arch ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
esac > /rust-target \ esac > /rust-target \
&& rustup target add "$(cat /rust-target)" && rustup target add "$(cat /rust-target)"
WORKDIR /src WORKDIR /src
@@ -36,6 +36,20 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry,sharin
# Data dir owned by the non-root runtime user. # Data dir owned by the non-root runtime user.
RUN mkdir -p /out/data && chown 65532:65532 /out/data RUN mkdir -p /out/data && chown 65532:65532 /out/data
# ---- 2b. Vantage generator (optional) ----------------------------------------
# The live map renders with Vantage. Point VANTAGE_URL at a static Linux
# release archive (tar.gz or zip) for the target arch to bake it into the image;
# leave it empty to mount your own binary at /opt/vantage/vantage instead.
FROM --platform=$BUILDPLATFORM alpine:3 AS vantage
ARG VANTAGE_URL=""
RUN apk add --no-cache curl tar unzip && mkdir -p /out/vantage /out/assets \
&& if [ -n "$VANTAGE_URL" ]; then \
curl -fsSL "$VANTAGE_URL" -o /tmp/vantage.pkg \
&& (tar -xzf /tmp/vantage.pkg -C /out/vantage 2>/dev/null || unzip -q /tmp/vantage.pkg -d /out/vantage) \
&& find /out/vantage -type f -name vantage -exec chmod 755 {} + \
&& (f=$(find /out/vantage -type f -name vantage | head -n1) && [ -n "$f" ] && [ "$f" != /out/vantage/vantage ] && mv "$f" /out/vantage/vantage || true); \
fi
# ---- 3. Runtime -------------------------------------------------------------- # ---- 3. Runtime --------------------------------------------------------------
FROM scratch FROM scratch
LABEL org.opencontainers.image.title="SCOPENET Panel" \ LABEL org.opencontainers.image.title="SCOPENET Panel" \
@@ -44,9 +58,12 @@ LABEL org.opencontainers.image.title="SCOPENET Panel" \
COPY --from=build /scopenet-panel /scopenet-panel COPY --from=build /scopenet-panel /scopenet-panel
COPY --from=web /src/panel/web/dist /web COPY --from=web /src/panel/web/dist /web
COPY --from=build --chown=65532:65532 /out/data /data COPY --from=build --chown=65532:65532 /out/data /data
COPY --from=vantage /out/vantage /opt/vantage
ENV SCOPENET_BIND=0.0.0.0:8080 \ ENV SCOPENET_BIND=0.0.0.0:8080 \
SCOPENET_DATA_DIR=/data \ SCOPENET_DATA_DIR=/data \
SCOPENET_WEB_DIR=/web \ SCOPENET_WEB_DIR=/web \
SCOPENET_VANTAGE_BIN=/opt/vantage/vantage \
SCOPENET_VANTAGE_ASSETS=/data/livemap/assets \
RUST_LOG=info RUST_LOG=info
USER 65532:65532 USER 65532:65532
VOLUME ["/data"] VOLUME ["/data"]
+13 -8
View File
@@ -25,7 +25,7 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
### Launcher features ### 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. - **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. - **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen. - **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
@@ -52,10 +52,12 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
### 1. Run the panel ### 1. Run the panel
```bash ```bash
curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml git clone https://github.com/scopeddlol/SCOPENET-MC.git
curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example cd SCOPENET-MC
# edit .env → set ADMIN_PASSWORD cp .env.example .env
docker compose up -d # 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: Open `http://your-server:8080`, sign in as `admin`, then:
@@ -64,7 +66,7 @@ Open `http://your-server:8080`, sign in as `admin`, then:
2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack. 2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack.
3. **Settings** → how players sign in. 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 ### 2. Build your branded launcher
@@ -82,7 +84,8 @@ The workflow builds the **Windows installer**, pushes the **panel image to GHCR*
``` ```
crates/shared Wire types shared by launcher and panel (manifest, branding, auth) 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/server Admin panel API (Axum + SQLite)
panel/web Admin panel UI (Svelte 5) panel/web Admin panel UI (Svelte 5)
launcher/ Desktop launcher UI (Svelte 5) launcher/ Desktop launcher UI (Svelte 5)
@@ -95,9 +98,11 @@ docker-compose.yml Panel deployment
## Documentation ## Documentation
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups - [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
- [Architecture](docs/architecture.md) — how the pieces fit, API reference - [Architecture](docs/architecture.md) — how the pieces fit, API reference
- [Development](docs/development.md) — running everything locally, tests - [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 ## Configuration reference
+2
View File
@@ -13,6 +13,8 @@ tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
futures.workspace = true futures.workspace = true
sha1.workspace = true sha1.workspace = true
sha2.workspace = true
base64.workspace = true
hex.workspace = true hex.workspace = true
zip.workspace = true zip.workspace = true
tracing.workspace = true tracing.workspace = true
+130
View File
@@ -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");
}
}
+7 -6
View File
@@ -27,14 +27,15 @@ pub struct Auth {
/// Dashed or undashed UUID. /// Dashed or undashed UUID.
pub uuid: String, pub uuid: String,
pub access_token: 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 user_type: String,
pub xuid: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LaunchOptions { pub struct LaunchOptions {
pub auth: Auth, pub auth: Auth,
/// JVM arguments that must come first (the authlib-injector agent).
pub agent_args: Vec<String>,
pub game_dir: PathBuf, pub game_dir: PathBuf,
pub memory_min_mb: u32, pub memory_min_mb: u32,
pub memory_max_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_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid)); vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new()); 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_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into()); vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone()); 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}")); 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!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb)); args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major)); args.extend(gc_args(opts.gc, installed.java_major));
@@ -336,8 +337,8 @@ mod tests {
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
access_token: "0".into(), access_token: "0".into(),
user_type: "legacy".into(), user_type: "legacy".into(),
xuid: None,
}, },
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
game_dir: "/g".into(), game_dir: "/g".into(),
memory_min_mb: 1024, memory_min_mb: 1024,
memory_max_mb: 4096, memory_max_mb: 4096,
@@ -362,7 +363,7 @@ mod tests {
let layout = Layout::new("/root"); let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true)); let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" "); 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("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f")); assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720")); assert!(a.contains("--width 1280 --height 720"));
+1 -1
View File
@@ -5,6 +5,7 @@
//! tested without a window. //! tested without a window.
pub mod assets; pub mod assets;
pub mod authlib;
pub mod http; pub mod http;
pub mod install; pub mod install;
pub mod java; pub mod java;
@@ -13,7 +14,6 @@ pub mod libraries;
pub mod loaders; pub mod loaders;
pub mod maven; pub mod maven;
pub mod meta; pub mod meta;
pub mod msa;
pub mod options; pub mod options;
pub mod paths; pub mod paths;
pub mod ping; pub mod ping;
-212
View File
@@ -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 })
}
+42
View File
@@ -4,6 +4,36 @@ use anyhow::Result;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path; 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 /// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes). /// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> { pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
@@ -51,4 +81,16 @@ mod tests {
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap(); 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"); 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"));
}
} }
+12 -1
View File
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
uuid: scopenet_shared::offline_uuid("CiBot"), uuid: scopenet_shared::offline_uuid("CiBot"),
access_token: "0".into(), access_token: "0".into(),
user_type: "legacy".into(), user_type: "legacy".into(),
xuid: None,
}, },
agent_args: vec![],
game_dir, game_dir,
memory_min_mb: 512, memory_min_mb: 512,
memory_max_mb: 2048, memory_max_mb: 2048,
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
async fn neoforge_1_21_1() { async fn neoforge_1_21_1() {
run("1.21.1", Loader::NeoForge).await; 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
View File
@@ -31,6 +31,7 @@ pub struct Branding {
pub version_label: String, pub version_label: String,
pub news: Vec<NewsItem>, pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>, pub links: Vec<SocialLink>,
pub about: AboutContent,
pub features: Features, pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users). /// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String, pub custom_css: String,
@@ -60,12 +61,22 @@ impl Default for Branding {
tag: Some("News".into()), tag: Some("News".into()),
}], }],
links: vec![], links: vec![],
about: AboutContent::default(),
features: Features::default(), features: Features::default(),
custom_css: String::new(), 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct Background { pub struct Background {
@@ -175,23 +186,72 @@ pub enum RegistrationMode {
pub struct AuthConfig { pub struct AuthConfig {
pub panel_accounts: bool, pub panel_accounts: bool,
pub registration: RegistrationMode, pub registration: RegistrationMode,
pub microsoft: bool,
pub microsoft_client_id: Option<String>,
pub offline_local: bool, 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 { impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
panel_accounts: true,
registration: RegistrationMode::Closed,
microsoft: false,
microsoft_client_id: None,
offline_local: true,
}
} }
} }
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
@@ -210,7 +270,8 @@ pub struct RegisterRequest {
pub struct PublicUser { pub struct PublicUser {
pub id: i64, pub id: i64,
pub username: String, 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 uuid: String,
pub role: String, pub role: String,
pub groups: Vec<String>, pub groups: Vec<String>,
@@ -218,11 +279,15 @@ pub struct PublicUser {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse { pub struct AuthResponse {
/// Panel session token (manifest, skins, account API).
pub token: String, pub token: String,
pub user: PublicUser, pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval. /// Set when the account exists but is waiting for admin approval.
#[serde(default)] #[serde(default)]
pub pending: bool, 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 description: String,
pub icon_url: Option<String>, pub icon_url: Option<String>,
pub banner_url: Option<String>, pub banner_url: Option<String>,
pub logo_url: Option<String>,
pub mc_version: String, pub mc_version: String,
pub loader: Loader, pub loader: Loader,
pub loader_version: Option<String>, pub loader_version: Option<String>,
@@ -354,6 +420,376 @@ pub struct LaunchEvent {
pub username: Option<String>, 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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+17 -6
View File
@@ -1,14 +1,18 @@
# SCOPENET admin panel # SCOPENET admin panel
# #
# cp .env.example .env # set ADMIN_PASSWORD at least # cp .env.example .env # set ADMIN_PASSWORD and PUBLIC_URL
# docker compose up -d # docker compose up -d --build
# #
# Then open http://localhost:8080 (or your domain behind a reverse proxy). # Then open http://localhost:8080 (or your domain behind a reverse proxy).
services: services:
panel: panel:
image: ghcr.io/scopeddlol/scopenet-mc-panel:latest # Tag the image built from this checkout so an older GHCR release is not used.
# Or build from source: docker compose build image: scopenet-panel:local
build: . build:
context: .
args:
# Optional: URL of a static Vantage Linux release to bundle.
VANTAGE_URL: ${VANTAGE_URL:-}
container_name: scopenet-panel container_name: scopenet-panel
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -23,6 +27,12 @@ services:
# Optional — can also be set in the panel's Settings page. # Optional — can also be set in the panel's Settings page.
CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-} CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
PUBLIC_URL: ${PUBLIC_URL:-}
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
# Live Map: Vantage generator + Minecraft client assets (see docs/live-map.md).
SCOPENET_VANTAGE_BIN: ${SCOPENET_VANTAGE_BIN:-/opt/vantage/vantage}
SCOPENET_VANTAGE_ASSETS: ${SCOPENET_VANTAGE_ASSETS:-/data/livemap/assets}
SCOPENET_VANTAGE_ARGS: ${SCOPENET_VANTAGE_ARGS:-}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-info}
volumes: volumes:
- panel-data:/data - panel-data:/data
@@ -30,7 +40,8 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 256M # Raise this if you enable Live Map: Vantage bakes tiles in memory.
memory: ${PANEL_MEMORY:-256M}
volumes: volumes:
panel-data: panel-data:
+26 -5
View File
@@ -4,7 +4,7 @@
```bash ```bash
cp .env.example .env # set ADMIN_PASSWORD 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 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. **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 ### 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). - *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). - *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 ### Access
- **Everyone** — any launcher, including offline and Microsoft accounts. - **Everyone** — any launcher, including local offline accounts.
- **Signed-in players** — any panel account. - **Signed-in players** — any panel account.
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything. - **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
## Players ## Players
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*. - **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. - Disabling an account signs it out everywhere on the next request.
- Ten failed logins lock an account for five minutes. - 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 ## Launcher design
@@ -76,3 +83,17 @@ Everything on this page is pushed to launchers when they start or refresh — no
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 GitHub Releases on start and offers the new installer.
**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`. **Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`.
## 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.
+1 -1
View File
@@ -18,7 +18,7 @@
## Launch pipeline ## 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). 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. 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. 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.
+46
View File
@@ -0,0 +1,46 @@
# 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.
To show a Vantage world map in the launcher, deploy Vantage next to each game
server and configure its manifest URL in the panel. See
[Live map setup](live-map.md). Vantage needs access to the game world's saved
files, so it runs alongside the game server rather than inside the panel
container.
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.
+4
View File
@@ -34,3 +34,7 @@ cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings
## Releasing ## 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. 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.
+56
View File
@@ -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.
+47
View File
@@ -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.
+73
View File
@@ -0,0 +1,73 @@
# Live Map (central Vantage)
> **Setting it up?** Follow the step-by-step guide: [vantage-setup.md](vantage-setup.md). This page is the overview.
The panel hosts a [Vantage](https://github.com/thoughts-on-things/vantage-mc) 3D map
**for every game server**, so nobody has to run Vantage next to Minecraft.
```
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
```
1. **Collect** – the SCOPENET Paper plugin / Fabric / Forge mod reads the world's
region files (only chunks newer than what the panel already has) and sends them to
the panel over HTTPS. Player positions stream over a WebSocket (HTTPS fallback).
2. **Store & render** – the panel keeps a region-file mirror per server under
`<data>/livemap/<server id>/world` and runs a `vantage server` sidecar over it on
demand (stopped again after 15 idle minutes). Sidecars listen on loopback only.
3. **Serve** – `GET /api/livemap/<server>/<dimension>/v1/worlds/default/…` proxies the
sidecar to signed-in launcher/admin sessions. Player markers come straight from
memory. One map per server and dimension (`overworld`, `the_nether`, `the_end`).
4. **View** – the launcher checks `GET /api/v1/servers/<id>/livemap`; when Live Map is
on for a server linked to the instance, the instance page shows a **Live Map**
button that opens the map inside the launcher (and the Guilds territory view uses
it too). Admins see the same map on *Servers → your server*.
Chunk data only reaches the panel when the game server has written it, so the map
follows autosaves (and `/save-all`) rather than every block change.
## Turn it on
1. **Servers → your server → Settings → Live Map** (on by default for new servers).
2. Update the plugin/mod. `livemap.enabled: true` (Paper `config.yml`) or
`livemap.enabled=true` (`config/scopenet.properties`) is the default; set it to
`false` to keep a server off the map locally.
3. The server page shows a checklist: generator, assets, world data, player positions.
## Panel requirements
Rendering needs two things on the machine running the panel:
| Setting | Meaning |
| --- | --- |
| `SCOPENET_VANTAGE_BIN` | The `vantage` executable (default: `vantage` on `PATH`, then `/opt/vantage/vantage`). |
| `SCOPENET_VANTAGE_ASSETS` | Minecraft client assets directory passed as `--assets` (default `<data>/livemap/assets`). |
| `SCOPENET_VANTAGE_ARGS` | Optional extra `vantage server` flags, e.g. `--radius 2048 --memory 1024`. |
With Docker, either mount your binary at `/opt/vantage/vantage` and the assets at
`/data/livemap/assets`, or bake a static Linux release in with
`VANTAGE_URL=<release archive> docker compose build`. Until both exist the panel still
stores what servers send and launchers show "the map is being prepared".
Give the container more than the default 256 MB if Live Map is on (`PANEL_MEMORY`).
## Security
* Game servers authenticate with their existing `sn_…` token; the endpoints refuse
servers whose Live Map switch is off.
* Viewers must be signed in; the launcher attaches its session token natively, so it
never reaches page scripts. Anyone with an account can view a server's map — use
the server's access rules and leave Live Map off for private worlds.
* The sidecar gets a random per-process bearer token and never leaves loopback.
## Ingest API (for reference)
All under `/api/server/v1/livemap/` with `Authorization: Bearer <server token>`:
`GET config`, `GET manifest?dim=`, `POST chunks?dim=` (binary records of
`i32 x, i32 z, u32 timestamp, u8 compression, u32 length, bytes`), `POST level`
(`level.dat`), `POST players`, `GET ws` (WebSocket: JSON `players` frames).
## External Vantage (advanced)
Under *Use an external Vantage map instead* you can still point a server at a
`manifest.json` / `world.json` you host yourself. It is only used when Live Map is off.
-25
View File
@@ -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).
+76
View File
@@ -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.
+254
View File
@@ -0,0 +1,254 @@
# Setting up the Vantage 3D Live Map
This is the complete, step-by-step guide. For a short overview of how the pieces fit, see [live-map.md](live-map.md).
**What you end up with:** every SCOPENET-connected server gets a 3D map inside the admin panel and a **Live Map** button in the launcher. You install Vantage **once, on the panel machine**. Nothing runs next to your Minecraft servers except the SCOPENET plugin or mod you already use.
```
Minecraft server ──(SCOPENET plugin/mod)──► Panel ──runs──► vantage server ──► Admin page + Launcher button
region files + players HTTPS / WebSocket mirror of the world (loopback only)
```
> **Two things you must provide on the panel machine:** the `vantage` program, and the Minecraft **client assets** (textures and models) that Vantage needs to draw blocks. The panel will not draw a map until both exist. Everything else works out of the box.
---
## 0. Before you start
| You need | Notes |
| --- | --- |
| A running SCOPENET panel | With a **Public address** set in Settings, served over HTTPS. |
| A game server running the SCOPENET plugin/mod | Paper plugin, or the Fabric/Forge mod. The server must be online with `online-mode=true` (the same requirement as SCOPENET itself). |
| RAM | Allow **1 GB or more** for the panel when Live Map is on (the default 256 MB is too small). See [sizing](#7-sizing-and-performance). |
| Disk | Roughly the size of the world's region files, plus the rendered tile cache (often the same again). |
| A copy of Minecraft Java Edition | Needed **once**, to extract assets (step 2). It can be on any computer. |
Vantage is an MIT-licensed third-party project: <https://github.com/thoughts-on-things/vantage-mc> (product site <https://vantage.beacon-mc.io>). Its own server documentation is the authority on its flags.
---
## 1. Get the `vantage` program
Download the latest **CLI release** for the panel machine's operating system and CPU from the Vantage releases page: <https://github.com/thoughts-on-things/vantage-mc/releases/latest>.
* **Docker panel image:** the image has no shell and no libraries (it is built `FROM scratch`), so it needs a **statically linked Linux build** of `vantage` for the right architecture (x86_64 or arm64). If the release you pick is not static, it will fail to start inside the container with a "not found" error. In that case run the panel on the host, or use a static build.
* **Bare-metal panel:** any build that runs on your OS works.
Put the file somewhere permanent and make it executable (`chmod 755 vantage`). Check it runs:
```sh
./vantage --help
```
---
## 2. Get the Minecraft client assets
Vantage draws blocks from the textures and models inside Minecraft's client `.jar`. You extract them once with Vantage's own command:
```sh
vantage extract
```
It automatically finds an installed Minecraft client jar (the launcher's `versions/<version>/<version>.jar`) and writes the assets out. Run `vantage extract --help` for the flags (for example to choose the jar or the output folder).
**Which version?** Use a client jar for the same Minecraft version your servers run, or a newer one. Newer is safer if the world contains newer blocks.
**Running extract on a different computer is fine.** Do it on your own PC (which has Minecraft installed), then copy the resulting folder to the panel machine. You do not need Minecraft on the server.
**Where the panel looks:** it passes the folder in `SCOPENET_VANTAGE_ASSETS` (default `<data dir>/livemap/assets`) to `vantage server --assets`. Vantage's own examples point `--assets` at a folder ending in `assets/minecraft`. So **copy the extracted folder to the panel, then point the setting at the folder Vantage expects**. If unsure, test it directly (this command should start and print that it is listening, then stop it with Ctrl+C):
```sh
vantage server /path/to/any/world --assets /path/to/your/assets/folder --out /tmp/vantage-test
```
If Vantage complains about missing assets, try the parent or the `minecraft` subfolder and use whichever works.
> **Licence note:** the assets are Mojang's. Extract them from a client you own, and do not redistribute them.
---
## 3. Install them on the panel
Pick **one** of the two layouts.
### Option A: Docker (recommended)
The provided `docker-compose.yml` already defines the three settings. Mount your files into the data volume or bake Vantage into the image.
**A1. Mount the files** (simplest; no rebuild). Put the binary and assets on the Docker host, then add volumes to the `panel` service in `docker-compose.yml`:
```yaml
volumes:
- panel-data:/data
- ./vantage/vantage:/opt/vantage/vantage:ro # the static binary from step 1
- ./vantage/assets:/data/livemap/assets:ro # the folder from step 2
```
`SCOPENET_VANTAGE_BIN` (`/opt/vantage/vantage`) and `SCOPENET_VANTAGE_ASSETS` (`/data/livemap/assets`) already default to those paths.
**A2. Bake the binary in** (assets still come from a mount):
```sh
VANTAGE_URL="https://…/vantage-x86_64-unknown-linux-musl.tar.gz" docker compose build
```
`VANTAGE_URL` must be a direct link to a `.tar.gz` or `.zip` containing the `vantage` file for the **build machine's target architecture**.
**Raise the memory limit** in `.env` (or the compose file):
```env
PANEL_MEMORY=1536M
```
Then recreate the container:
```sh
docker compose up -d --build
```
### Option B: Panel running directly on the host
```sh
export SCOPENET_VANTAGE_BIN=/opt/vantage/vantage
export SCOPENET_VANTAGE_ASSETS=/var/lib/scopenet/livemap/assets
# Optional extra flags for `vantage server`:
export SCOPENET_VANTAGE_ARGS="--memory 1024 --threads 4"
```
If you leave the two variables unset, the panel looks for `vantage` on `PATH` and then `/opt/vantage/vantage`, and for assets in `<data dir>/livemap/assets`. With systemd, put these in the unit's `Environment=` lines.
---
## 4. Turn the map on for a server
1. In the panel: **Servers → your server → Settings → Live Map** (on by default for new servers). Save.
2. On the game server, make sure the plugin/mod has Live Map enabled. It is on by default:
* **Paper** `plugins/SCOPENET/config.yml`: `livemap.enabled: true`
* **Fabric/Forge** `config/scopenet.properties`: `livemap.enabled=true`
3. **Restart the game server** (or reload the SCOPENET config) so it starts sending data.
The plugin reads the world's saved region files, sends only chunks newer than what the panel already has, and streams player positions every second.
**World location:** the plugin reads the main world's folder (the first world Paper loads, normally `world`), and the Fabric/Forge mod reads `level-name` from `server.properties`. The Nether and End are found either inside that folder (`DIM-1`, `DIM1`) or in Paper's sibling folders (`world_nether`, `world_the_end`).
---
## 5. Check that it works
Open **Servers → your server**. The Live Map card shows four checks:
| Check | Green when | If it is red |
| --- | --- | --- |
| **Vantage generator** | The panel found the `vantage` file. | Fix `SCOPENET_VANTAGE_BIN` / the mount (step 3). |
| **Minecraft assets** | The assets folder exists. | Fix `SCOPENET_VANTAGE_ASSETS` (step 2–3). |
| **World data** | Region files have arrived. | Wait for the server to upload. Big worlds take a while the first time. |
| **Player positions** | Players are online and the server is sending them. | Have someone join; positions only flow while the server runs the plugin/mod. |
When all are green the map appears under **Overworld / Nether / End** tabs. In the launcher, the instance linked to that server shows a **Live Map** button. (The instance must be linked to the server in the panel, under the server's **Launcher instance** setting.)
**How fresh is it?** Chunk data only reaches the panel once the game server has *written it to disk*. The map therefore follows the server's autosaves (every few minutes by default). To refresh right now, run `/save-all` on the game server. Player positions are real-time.
---
## 6. How it behaves (good to know)
* **Sidecars start on demand.** The panel starts one `vantage server` per server and dimension when someone opens a map, and **stops it after 15 idle minutes**. The first open after idle takes a few seconds.
* **Loopback only.** The sidecars listen on `127.0.0.1` with a random per-process token. They are never exposed. All map traffic goes through the panel and needs a signed-in session.
* **The world mirror** lives in `<data>/livemap/<server id>/world`, and rendered tiles in a cache beside it. **Servers → Live Map → Reset map data** deletes both; the game server then uploads everything again.
* **Pre-baking.** Vantage renders tiles in the background around spawn and players, so the map gets smoother after the first visit.
---
## 7. Sizing and performance
Put extra `vantage server` flags in `SCOPENET_VANTAGE_ARGS` (space separated). The useful ones:
| Flag | What it does |
| --- | --- |
| `--memory <MB>` | Memory budget for rendering. Start at `1024`. |
| `--threads <n>` | Maximum parallel tile renders. Give at least `2`. |
| `--radius <blocks>` | Limit how far from spawn the map extends. Saves CPU and disk on huge worlds. |
| `--prebake off` | Only render what people look at. Lower CPU, slower first views. |
| `--lod off` | Skip the zoomed-out overview. Smaller cache, but the map is only visible where tiles are loaded. |
| `--scan-interval <s>` | How often it checks for changed regions (default `5`). |
| `--caves`, `--light`, `--biome-blend` | Render quality options. See Vantage's docs. |
Rules of thumb:
* **1 server, modest world:** 1 GB panel memory, `--threads 2`.
* **Several servers:** each open map dimension is its own process, and they stop after 15 idle minutes. Budget memory for the number of maps opened at once, not the number of servers.
* **Huge worlds:** set `--radius`, or `--prebake off`, and be patient on the first upload.
* **Disk:** watch `<data>/livemap`. **Reset map data** frees it.
---
## 8. Reverse proxy and HTTPS
The map is served from the panel's own address, so no new ports are needed. If the panel sits behind nginx, Caddy, Traefik or Cloudflare:
* Forward **WebSocket upgrades** to `/api/server/v1/livemap/ws`. The plugin falls back to plain HTTPS if WebSockets are blocked, but positions update faster with them.
* Allow **large request bodies** for chunk uploads (`client_max_body_size 64m;` in nginx).
* Do not cache `/api/livemap/` responses.
* Keep the panel's **Public address** set to the HTTPS URL players use.
---
## 9. Privacy and access
* Anyone with a **signed-in account** can open a server's map. To keep a world private, leave Live Map **off** for that server, or restrict the server with its access rules.
* The panel feeds Vantage **only the players who are online right now** (positions from the server plugin/mod). It does **not** expose the last-known positions of everyone who has ever logged in.
* Game servers authenticate with their normal `sn_…` token; servers with Live Map off are refused.
---
## 10. Troubleshooting
| You see | Cause and fix |
| --- | --- |
| Generator "Not installed on the panel" | The panel can't find `vantage`. Check `SCOPENET_VANTAGE_BIN`, the volume mount, and that the file is executable. In Docker, check the file is a **static** Linux build for the right CPU. |
| "Minecraft client assets for Vantage aren't configured" | The assets folder is missing or the path is wrong. Re-check step 2–3 and that the folder is readable by the container user (uid 65532). |
| "Waiting for the game server to upload this world" | The panel has no `level.dat` or region files for that dimension yet. Check the plugin/mod is connected (server page shows Online), Live Map is on in **both** the panel and the plugin config, and wait for an autosave or run `/save-all`. |
| Launcher shows "the map is being prepared" | Same as above, or the generator/assets check is red. |
| "couldn't start the Vantage generator" | The file isn't executable, is for another CPU/OS, or (in Docker) isn't static. Try running it by hand. |
| Map loads but is blank or shows pink/black blocks | Wrong or incomplete assets. Re-extract from a client jar of the right version. |
| Map is out of date | Chunks appear only after the server saves. Run `/save-all`, or lower the autosave interval in the server settings. |
| Some dimensions are greyed out | That dimension has no region data yet. Visit it in-game and save. |
| Everything is slow | Raise `PANEL_MEMORY` and `--threads`, set `--radius`, or use `--prebake off`. |
| No players on the map | Positions only arrive while the plugin/mod is running and someone is online. Confirm the "Player positions" check. |
| Panel logs: generator stopped | Normal after 15 idle minutes. It restarts on the next open. |
Still stuck? Open the server page, read **"Generator: …"** under the checklist (it shows the last error from Vantage), and check the panel logs with `RUST_LOG=debug`.
---
## 11. Reference
**Panel settings (environment variables)**
| Variable | Default | Meaning |
| --- | --- | --- |
| `SCOPENET_VANTAGE_BIN` | `vantage` on `PATH`, then `/opt/vantage/vantage` | The Vantage executable. |
| `SCOPENET_VANTAGE_ASSETS` | `<data dir>/livemap/assets` | Extracted Minecraft client assets. |
| `SCOPENET_VANTAGE_ARGS` | none | Extra flags appended to `vantage server`. |
| `PANEL_MEMORY` (Docker) | `256M` | Container memory limit. Use `1G` or more for Live Map. |
| `VANTAGE_URL` (Docker build arg) | empty | Direct link to a static Vantage release to bake into the image. |
**Game server settings**
| Where | Key | Default |
| --- | --- | --- |
| Paper `config.yml` | `livemap.enabled` | `true` |
| Fabric/Forge `config/scopenet.properties` | `livemap.enabled` | `true` |
| Panel, per server | Live Map switch | on for new servers |
**What the panel runs for each map** (for reference):
```
vantage server <data>/livemap/<id>/world --assets <assets> --out <cache> \
--host 127.0.0.1 --port <random> --players-file <players.json> --dimension <dim> <your extra flags>
```
with the secret passed in `VANTAGE_SERVER_TOKEN`.
**Not verified here:** the exact names of Vantage's release files and the exact folder layout that `vantage extract` produces change between Vantage versions. This guide was written against Vantage's published README and server documentation; follow Vantage's own `--help` if they differ.
+11
View File
@@ -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,54 @@
# 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
# Send world chunks and player positions to the panel's Live Map
# (also needs Live Map switched on for this server in the panel).
livemap.enabled=true
# --- Essentials (1.20.1) -----------------------------------------------------
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
essentials.max_homes=5
essentials.rtp_radius=2500
essentials.tpa_timeout_seconds=60
# --- 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
+134
View File
@@ -0,0 +1,134 @@
# ==============================================================================
# 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 per player.
max-homes: 5
# /rtp picks a random safe spot within this many blocks of 0,0.
rtp-radius: 2500
livemap:
# Send world chunks and player positions to the panel's central 3D Live Map.
# Nothing runs on this server; the panel renders it. Needs "Live Map" switched on
# for this server in the panel, and Vantage installed on the panel.
# Setup guide: docs/vantage-setup.md
enabled: true
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
# ==============================================================================
# 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.
# ==============================================================================
+19
View File
@@ -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",'));
+47
View File
@@ -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),');
});
+7
View File
@@ -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,44 @@
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 rankPrefix) {
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; }
}
+12
View File
@@ -0,0 +1,12 @@
allprojects {
group = 'net.scopenet'
version = providers.gradleProperty('releaseVersion').getOrElse('0.4.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() }
}
+5
View File
@@ -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,242 @@
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);
}
/** 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,45 @@
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;
private final List<CoreCommand> commands = new ArrayList<>();
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"));
EconomyCommands economy = new EconomyCommands(env, jobs);
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
add(env, EssentialsCommands.build(env, essentials), "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); }
/** Call about once a second from the server thread. */
public void tick() { jobs.tick(); }
/** 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,192 @@
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;
EconomyCommands(Env env, Jobs jobs) { this.env = env; this.jobs = jobs; }
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") : 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 (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,5 @@
package net.scopenet.core;
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
}
@@ -0,0 +1,239 @@
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; }
// ---- 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 (!mine.containsKey(name) && mine.size() >= config.maxHomes()) {
p.send(RED + "You have reached your limit of " + config.maxHomes() + " 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 -------------------------------------------------------------
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) {
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()));
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();
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;
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); }
private void go(CorePlayer p, Pos destination) {
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,302 @@
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");
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") : 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 "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"},
};
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);
}
});
}
// ---- 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,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,22 @@
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);
}
@@ -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 -----------------------------
static JsonObject obj(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
}
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;
}
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;
}
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,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,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,91 @@
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) {}
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());
}
/** 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());
}
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,208 @@
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 LiveMapSync liveMap;
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 mirroring this server's world and player positions to the panel's
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
*/
public void enableLiveMap(java.nio.file.Path worldRoot) {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client, worldRoot, log);
liveMap.start();
}
/** Hand over a snapshot of player positions; call about once a second on the server thread. */
public void liveMapPlayers(java.util.List<LiveMapSync.Player> players) {
LiveMapSync map = liveMap;
if (map != null) map.offerPlayers(players);
}
/** The world was just saved: pick up its changed chunks soon. */
public void liveMapSaved() {
LiveMapSync map = liveMap;
if (map != null) map.requestScan();
}
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;
LiveMapSync map = liveMap;
if (map != null) map.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,363 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
* already-compressed Anvil payloads read straight from the region files) over
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
* Nothing here touches the server thread except {@link #offerPlayers}.
*/
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private static final String[] DIMS = {"overworld", "the_nether", "the_end"};
private static final int SECTOR = 4096;
private static final int BATCH_BYTES = 6 * 1024 * 1024;
private static final int SCAN_BUDGET_BYTES = 48 * 1024 * 1024;
private final PanelClient client;
private final Path root;
private final Consumer<String> log;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-livemap");
t.setDaemon(true);
return t;
});
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
private final Map<String, int[]> known = new HashMap<>();
private final Set<String> manifestLoaded = new HashSet<>();
private final AtomicBoolean scanQueued = new AtomicBoolean();
private volatile boolean panelEnabled;
private volatile boolean closed;
private volatile List<Player> players = List.of();
private volatile boolean playersChanged;
private volatile WebSocket socket;
private volatile long socketRetryAt;
private long nextScan;
private long configCheckAt;
private long levelModified = -1;
private long lastPlayersSent;
private long chunkIntervalSecs = 20;
private boolean warnedMissing;
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
this.client = client;
this.root = root;
this.log = log;
}
public void start() {
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
playersChanged = true;
}
}
/** A save just happened; scan soon instead of waiting for the next interval. */
public void requestScan() {
if (closed || !scanQueued.compareAndSet(false, true)) return;
try {
worker.schedule(() -> {
scanQueued.set(false);
nextScan = 0;
scanSafely();
}, 3, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
scanQueued.set(false);
}
}
@Override public void close() {
closed = true;
WebSocket ws = socket;
if (ws != null) ws.abort();
worker.shutdownNow();
}
// ---- chunks ----
private void scanSafely() {
if (closed || System.nanoTime() < nextScan) return;
try {
if (!refreshEnabled()) return;
long uploaded = scan();
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
}
}
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
private boolean refreshEnabled() throws IOException, InterruptedException {
if (panelEnabled) return true;
long now = System.nanoTime();
if (now < configCheckAt) return false;
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
try {
JsonObject config = client.get("livemap/config");
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
} catch (PanelClient.HttpFailure e) {
panelEnabled = false; // an older panel without live map support
}
return panelEnabled;
}
/** Returns how many bytes of chunk data were uploaded. */
private long scan() throws IOException, InterruptedException {
if (!Files.isDirectory(root)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET live map: world folder " + root + " not found");
}
return 0;
}
uploadLevelIfChanged();
long total = 0;
for (String dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
loadManifest(dim);
Batch batch = new Batch(dim);
List<Path> files = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
stream.forEach(files::add);
}
files.sort(Comparator.comparing(Path::toString));
for (Path file : files) {
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
total += readRegion(dim, file, batch);
}
total += batch.flush();
if (total >= SCAN_BUDGET_BYTES) break;
}
return total;
}
private Path regionDir(String dim) {
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
Path parent = root.toAbsolutePath().getParent();
List<Path> candidates = switch (dim) {
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
root.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
root.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
private void loadManifest(String dim) throws IOException, InterruptedException {
if (manifestLoaded.contains(dim)) return;
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
for (JsonElement el : manifest.getAsJsonArray("regions")) {
JsonObject region = el.getAsJsonObject();
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
int[] ts = new int[1024];
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
}
manifestLoaded.add(dim);
}
private static String key(String dim, int rx, int rz) {
return dim + ":" + rx + ":" + rz;
}
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
return 0;
}
long flushed = 0;
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long size = ch.size();
if (size < 2L * SECTOR) return 0;
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
for (int i = 0; i < 1024; i++) {
int loc = header.getInt(i * 4);
int ts = header.getInt(SECTOR + i * 4);
int count = loc & 0xff;
long offset = (loc >>> 8) & 0xffffffL;
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
if (offset < 2 || (offset + count) * SECTOR > size) continue;
ByteBuffer prefix = ByteBuffer.allocate(5);
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
if (prefix.hasRemaining()) continue;
int length = prefix.getInt(0);
int compression = prefix.get(4) & 0xff;
// Oversized chunks live in external .mcc files; skip those and anything malformed.
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
ByteBuffer data = ByteBuffer.allocate(length - 1);
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
if (data.hasRemaining()) continue;
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
}
}
return flushed;
}
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
private final class Batch {
final String dim;
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<int[]> marks = new ArrayList<>();
int size;
Batch(String dim) {
this.dim = dim;
}
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
out.write(rec.array(), 0, rec.capacity());
marks.add(new int[]{rx, rz, index, ts});
size += rec.capacity();
}
long flush() throws IOException, InterruptedException {
if (marks.isEmpty()) return 0;
long sent = size;
try {
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
} finally {
out.reset();
marks.clear();
size = 0;
}
return sent;
}
}
private void uploadLevelIfChanged() throws IOException, InterruptedException {
Path level = root.resolve("level.dat");
if (!Files.isRegularFile(level)) return;
long modified = Files.getLastModifiedTime(level).toMillis();
if (modified == levelModified) return;
byte[] bytes = Files.readAllBytes(level);
if (bytes.length > 2 * 1024 * 1024) return;
client.postBytes("livemap/level", bytes);
levelModified = modified;
}
// ---- players ----
private void sendPlayersSafely() {
if (closed || !panelEnabled) return;
long now = System.nanoTime();
// Resend unchanged rosters now and then so the panel knows we're alive.
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
if (!playersChanged && !heartbeat) return;
playersChanged = false;
lastPlayersSent = now;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.addProperty("yaw", p.yaw());
o.addProperty("pitch", p.pitch());
list.add(o);
}
JsonObject body = new JsonObject();
body.addProperty("type", "players");
body.add("players", list);
try {
WebSocket ws = socket;
if (ws != null && !ws.isOutputClosed()) {
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
return;
}
connectSocket();
client.post("livemap/players", body);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
socket = null; // reconnect later; HTTPS covers the gap
}
}
private void connectSocket() {
long now = System.nanoTime();
if (now < socketRetryAt) return;
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
try {
Settings settings = client.settings();
URI panel = settings.panel();
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
client.http().newWebSocketBuilder()
.header("Authorization", "Bearer " + settings.token())
.connectTimeout(Duration.ofSeconds(5))
.buildAsync(uri, new WebSocket.Listener() {
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
ws.request(1);
return null;
}
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
if (socket == ws) socket = null;
return null;
}
@Override public void onError(WebSocket ws, Throwable error) {
if (socket == ws) socket = null;
}
}).thenAccept(ws -> {
if (closed) ws.abort();
else socket = ws;
});
} catch (RuntimeException ignored) {
// The HTTPS fallback keeps working.
}
}
}
@@ -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,168 @@
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 central live map (the panel also has a per-server switch). */
public static boolean liveMapEnabled(Path path) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
Properties properties = new Properties();
properties.load(reader);
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
} 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
# Send world chunks and player positions to the panel's central Live Map.
# It only runs when Live Map is also enabled for this server in the panel.
livemap.enabled=true
""";
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,282 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import net.scopenet.integration.PanelClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class CommandsTest {
@TempDir Path dir;
Kit kit;
CommandSet set;
Kit.Player alex, steve;
@BeforeEach void setup() {
kit = new Kit(dir);
alex = kit.platform.add("Alex");
steve = kit.platform.add("Steve");
set = new CommandSet(kit.env, EssentialsConfig.defaults());
}
void run(Kit.Player p, String name, String... args) { Kit.find(set.all(), name).run(p, args); }
// ---- gating -------------------------------------------------------------------------
@Test void featureSwitchesAndPermissionsGateEveryCommand() {
alex.denied.add("scopenet.command.home");
run(alex, "home");
assertTrue(alex.last().contains("do not have permission"));
kit.env.features = new Features(false, true, true, true, true, true, "$");
run(steve, "sethome");
assertEquals("Essentials are disabled.", steve.last());
kit.env.features = new Features(true, false, true, true, true, true, "$");
run(steve, "balance");
assertEquals("Economy are disabled.", steve.last());
}
@Test void everyPaperCommandExists() {
var names = new java.util.HashSet<String>();
set.all().forEach(c -> names.add(c.name()));
for (String n : List.of("spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime",
"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions", "guild", "claim", "unclaim"))
assertTrue(names.contains(n), n);
assertTrue(Kit.find(set.all(), "balance").aliases().contains("bal"));
assertEquals("scopenet.command.guild", Kit.find(set.all(), "guild").permission());
}
// ---- essentials wiring ---------------------------------------------------------------
@Test void essentialsCommandsWork() {
run(alex, "spawn");
assertEquals(80, alex.pos.y());
run(alex, "playtime");
assertTrue(alex.last().contains("1h 1m 40s"));
run(alex, "playtime", "steve");
assertTrue(alex.heard("Playtime: Steve"));
run(alex, "sethome", "a");
run(alex, "sethome", "b");
run(alex, "home");
assertTrue(alex.last().contains("a, b"), alex.last());
assertEquals(List.of("a", "b"), Kit.find(set.all(), "home").complete(alex, new String[]{""}));
assertEquals(List.of("Steve"), Kit.find(set.all(), "tpa").complete(alex, new String[]{""}));
}
// ---- economy -------------------------------------------------------------------------
@Test void balancePayAndBaltop() {
kit.panel.on("economy/balance", "{\"balance\":1234.5}");
run(alex, "balance");
assertTrue(alex.heard("Your Balance: $1,234.50"));
kit.panel.on("economy/transfer", "{\"from_balance\":900.0}");
run(alex, "pay", "steve", "100");
assertTrue(alex.heard("Sent $100.00 to Steve. New Balance: $900.00"));
assertTrue(steve.heard("Received $100.00 from Alex"));
JsonObject sent = kit.panel.bodies.get(kit.panel.bodies.size() - 1);
assertEquals(steve.id.toString(), sent.get("to_uuid").getAsString());
run(alex, "pay", "steve", "-5");
assertTrue(alex.last().contains("Invalid amount"));
run(alex, "pay", "alex", "5");
assertTrue(alex.last().contains("yourself"));
run(alex, "pay", "ghost", "5");
assertTrue(alex.last().contains("not online"));
kit.panel.handlers.put("economy/transfer", b -> { throw new PanelClient.HttpFailure(400, "Insufficient funds"); });
run(alex, "pay", "steve", "1");
assertTrue(alex.last().contains("Transfer failed: Insufficient funds"));
kit.panel.on("economy/baltop", "[{\"rank\":1,\"username\":\"Mia\",\"balance\":9000},{\"rank\":2,\"username\":\"Alex\",\"balance\":5}]");
run(alex, "baltop");
assertTrue(alex.heard("#1 Mia: $9,000.00"));
}
@Test void sellHandIsEscrowedThenPaid() {
kit.panel.on("economy/adjust", "{\"balance\":70.0}");
alex.held = new Item("DIAMOND", "Diamond", 2, "");
run(alex, "sell", "hand");
assertNull(alex.held, "taken into escrow");
assertEquals(1, set.jobs.pending());
set.tick();
assertEquals(0, set.jobs.pending());
JsonObject body = kit.panel.bodies.get(0);
assertEquals(70.0, body.get("delta").getAsDouble());
assertTrue(body.has("operation_id"));
assertTrue(alex.heard("Transaction completed. Balance: $70.00"));
assertTrue(alex.inventory.isEmpty());
run(alex, "sell", "hand");
assertTrue(alex.last().contains("not holding"));
alex.held = new Item("DIRT", "Dirt", 1, "");
run(alex, "sell", "hand");
assertTrue(alex.last().contains("cannot be sold"));
}
@Test void rejectedSaleReturnsTheItemsAndOutagesRetryTheSameOperation() {
alex.held = new Item("DIAMOND", "Diamond", 1, "nbt");
List<String> ops = new java.util.ArrayList<>();
kit.panel.handlers.put("economy/adjust", b -> { ops.add(b.get("operation_id").getAsString()); throw new java.io.IOException("connection refused"); });
run(alex, "sell", "hand");
set.tick();
set.tick();
assertEquals(1, set.jobs.pending(), "outage keeps the job");
assertEquals(2, ops.size());
assertEquals(ops.get(0), ops.get(1), "same operation id on retry");
kit.panel.handlers.put("economy/adjust", b -> { throw new PanelClient.HttpFailure(400, "Sales are paused"); });
set.tick();
assertEquals(0, set.jobs.pending());
assertEquals(1, alex.inventory.size());
assertEquals("nbt", alex.inventory.get(0).data(), "the very same stack comes back");
assertTrue(alex.heard("Transaction rejected; held items returned: Sales are paused"));
}
@Test void escrowSurvivesARestart() {
alex.held = new Item("DIAMOND", "Diamond", 1, "");
run(alex, "sell", "hand");
CommandSet reopened = new CommandSet(kit.env, EssentialsConfig.defaults());
assertEquals(1, reopened.jobs.pending());
kit.panel.on("economy/adjust", "{\"balance\":35.0}");
reopened.tick();
assertEquals(0, reopened.jobs.pending());
assertTrue(alex.heard("Balance: $35.00"));
}
@Test void marketListBuyAndSell() {
kit.panel.on("economy/market", "[{\"id\":7,\"seller_name\":\"Mia\",\"seller_guild\":null,\"item_id\":\"DIAMOND\",\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}]");
run(alex, "market");
assertTrue(alex.heard("#7 Diamond x3 $99.50 by Mia"));
kit.panel.on("economy/market/buy", "{\"item_id\":\"DIAMOND\",\"amount\":3,\"new_balance\":10.0}");
run(alex, "market", "buy", "#7");
set.tick();
assertEquals(1, alex.inventory.size());
assertEquals(3, alex.inventory.get(0).count());
assertEquals(7, kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("listing_id").getAsLong());
kit.panel.on("economy/market/list", "{\"id\":8}");
alex.held = new Item("IRON_INGOT", "Iron Ingot", 5, "x");
run(alex, "market", "sell", "12.5");
set.tick();
JsonObject listed = kit.panel.bodies.stream().filter(b -> b.has("seller_uuid")).findFirst().orElseThrow();
assertEquals("IRON_INGOT", listed.get("item_id").getAsString());
assertEquals(5, listed.get("amount").getAsInt());
assertEquals(12.5, listed.get("price").getAsDouble());
alex.denied.add("scopenet.command.market.sell");
alex.held = new Item("IRON_INGOT", "Iron Ingot", 5, "");
run(alex, "market", "sell", "1");
assertTrue(alex.last().contains("permission"));
assertNotNull(alex.held, "nothing taken without permission");
}
// ---- guilds --------------------------------------------------------------------------
private void inGuild(String role) {
kit.panel.on("guilds/player", "{\"in_guild\":true,\"guild\":{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"role\":\"" + role + "\",\"level\":2,\"claims_count\":3,\"max_claims\":16,"
+ "\"members\":[{\"name\":\"Alex\",\"role\":\"leader\"},{\"name\":\"Steve\",\"role\":\"member\"},{\"name\":\"Mia\",\"role\":\"member\"}]}}");
}
@Test void guildCreateClaimAndPermissions() {
kit.panel.on("guilds/create", "{}");
run(alex, "guild", "create", "Iron", "IRON");
assertTrue(alex.heard("Guild Iron [IRON] founded"));
assertTrue(kit.panel.claimsRefreshed);
run(alex, "guild", "create", "Only");
assertTrue(alex.last().startsWith("Usage"));
alex.pos = new Pos("minecraft:overworld", 35, 64, -20, 0, 0); // chunk 2, -2
kit.panel.on("guilds/claim", "{\"guild_name\":\"Iron\"}");
run(alex, "claim");
assertTrue(alex.heard("Chunk claimed successfully for Iron"));
JsonObject body = kit.panel.bodies.get(kit.panel.bodies.size() - 1);
assertEquals(2, body.get("chunk_x").getAsInt());
assertEquals(-2, body.get("chunk_z").getAsInt());
assertEquals("minecraft:overworld", body.get("dimension").getAsString());
alex.denied.add("scopenet.command.guild.leave");
run(alex, "guild", "leave");
assertTrue(alex.last().contains("permission to use /guild leave"));
alex.denied.add("scopenet.command.guild.sell.hand");
run(alex, "guild", "sell", "hand");
assertTrue(alex.last().contains("/guild sell hand"));
}
@Test void guildInfoMembersChatAndMap() {
inGuild("leader");
run(alex, "guild");
assertTrue(alex.heard("Guild: Iron [IRON]"));
assertTrue(alex.heard("Claimed chunks: 3 / 16"));
run(alex, "guild", "members");
assertTrue(alex.heard("Steve (member) [Online]"));
assertTrue(alex.heard("Mia (member) [Offline]"));
kit.platform.add("Bob");
run(alex, "guild", "chat", "hello", "team");
assertTrue(steve.heard("[Guild IRON] Alex: hello team"));
Kit.Player bob = kit.platform.online.values().stream().filter(p -> p.name.equals("Bob")).findFirst().orElseThrow();
assertFalse(bob.heard("hello team"), "non-members don't see guild chat");
kit.panel.claims.put("minecraft:overworld:1:0", new ChunkCheckResult(true, true, "Iron", "IRON"));
kit.panel.claims.put("minecraft:overworld:-1:0", new ChunkCheckResult(true, false, "Void", "VOID"));
run(alex, "guild", "map");
assertTrue(alex.heard("Territory Map (0, 0)"));
assertTrue(alex.inbox.stream().anyMatch(s -> s.contains("[P]") && s.contains("x") && s.contains("+")));
}
@Test void guildHomeNeedsOfficerAndPersists() {
inGuild("member");
run(steve, "guild", "sethome");
assertTrue(steve.last().contains("Only guild leaders and officers"));
inGuild("leader");
alex.pos = new Pos("minecraft:overworld", 100, 64, 100, 0, 0);
run(alex, "guild", "sethome");
assertTrue(alex.last().contains("Guild home waypoint set"));
CommandSet reopened = new CommandSet(kit.env, EssentialsConfig.defaults());
alex.pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
Kit.find(reopened.all(), "guild").run(alex, new String[]{"home"});
assertEquals(100, alex.pos.x());
}
@Test void guildBankBalanceDepositAndPay() {
kit.panel.on("guilds/bank", "{\"guild\":{\"tag\":\"IRON\",\"name\":\"Iron\"},\"role\":\"leader\",\"balance\":150.5,\"my_balance\":749.5,"
+ "\"recent\":[{\"kind\":\"deposit\",\"who\":\"Steve\",\"amount\":250.5,\"note\":\"\",\"at\":\"x\"},{\"kind\":\"withdraw\",\"who\":\"Alex\",\"amount\":100,\"note\":\"\",\"at\":\"x\"}]}");
run(alex, "guild", "bank");
assertTrue(alex.heard("[IRON] Iron Bank"));
assertTrue(alex.heard("Balance: $150.50"));
assertTrue(alex.heard("-$100.00 withdraw by Alex"));
kit.panel.on("guilds/bank/transfer", "{\"message\":\"Deposited $50.00 into [IRON] bank.\",\"balance\":200.5}");
run(alex, "guild", "bank", "deposit", "50");
set.tick();
assertTrue(alex.heard("Deposited $50.00 into [IRON] bank. Bank: $200.50"));
run(alex, "guild", "bank", "withdraw", "0.001");
assertTrue(alex.last().contains("at least 0.01"));
alex.denied.add("scopenet.command.guild.bank.withdraw");
run(alex, "guild", "bank", "withdraw", "5");
assertTrue(alex.last().contains("permission to withdraw"));
kit.panel.on("guilds/bank/pay", "{\"message\":\"Paid $10.00 to [VOID].\",\"balance\":190.5}");
run(alex, "guild", "pay", "VOID", "10");
set.tick();
assertTrue(kit.panel.calls.contains("guilds/bank/pay"));
assertEquals("VOID", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("to_tag").getAsString());
}
@Test void guildSellHandCreditsTheBank() {
kit.panel.on("guilds/bank/credit", "{\"message\":\"Sold to the shop: $70.00 into [IRON] bank.\",\"balance\":70.0}");
alex.held = new Item("DIAMOND", "Diamond", 2, "");
run(alex, "guild", "sell", "hand");
set.tick();
JsonObject body = kit.panel.bodies.get(0);
assertEquals(70.0, body.get("amount").getAsDouble());
assertFalse(body.has("delta"));
assertTrue(alex.heard("Bank: $70.00"));
}
}
@@ -0,0 +1,144 @@
package net.scopenet.core;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
class EssentialsServiceTest {
static final class Fake implements CorePlayer {
final UUID id = UUID.randomUUID();
final String name;
Pos pos = new Pos("overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>();
Fake(String name) { this.name = name; }
public UUID uuid() { return id; }
public String name() { return name; }
public Pos pos() { return pos; }
public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return true; }
public long playtimeSeconds() { return 0; }
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
public void clearHeld() { }
public void give(Item i) { }
String last() { return inbox.get(inbox.size() - 1); }
}
final Map<UUID, Fake> online = new HashMap<>();
final AtomicLong now = new AtomicLong(1_000);
boolean safe = true;
@TempDir Path dir;
Platform platform = new Platform() {
public Optional<CorePlayer> player(UUID u) { return Optional.ofNullable(online.get(u)); }
public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); }
public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); }
public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); }
public java.util.Collection<? extends CorePlayer> online() { return online.values(); }
public void runMain(Runnable r) { r.run(); }
public void runAsync(Runnable r) { r.run(); }
};
Fake alex, steve;
EssentialsService svc;
@BeforeEach void setup() {
alex = new Fake("Alex"); steve = new Fake("Steve");
online.put(alex.id, alex); online.put(steve.id, steve);
svc = new EssentialsService(platform, dir.resolve("essentials.json"), EssentialsConfig.defaults(), now::get, new Random(1));
}
@Test void homesAreLimitedAndPersisted() {
svc.configure(new EssentialsConfig(2, 2500, 60_000, 15));
alex.pos = new Pos("overworld", 10, 64, 20, 90, 0);
svc.setHome(alex, new String[]{"Base"});
svc.setHome(alex, new String[]{"mine"});
svc.setHome(alex, new String[]{"third"});
assertTrue(alex.last().contains("limit of 2"));
svc.setHome(alex, new String[]{"base"}); // overwriting is allowed at the limit
assertEquals(List.of("base", "mine"), svc.homeNames(alex.id));
EssentialsService reloaded = new EssentialsService(platform, dir.resolve("essentials.json"), EssentialsConfig.defaults(), now::get, new Random(1));
assertEquals(10, reloaded.homeAt(alex.id, "BASE").orElseThrow().x());
alex.pos = new Pos("overworld", 0, 0, 0, 0, 0);
assertEquals(List.of("base", "mine"), reloaded.home(alex, new String[0]), "several homes need a choice");
reloaded.home(alex, new String[]{"base"});
assertEquals(10, alex.pos.x());
}
@Test void singleHomeTeleportsDirectlyAndDeleteWorks() {
svc.home(alex, new String[0]);
assertTrue(alex.last().contains("no homes"));
svc.setHome(alex, new String[0]);
alex.pos = new Pos("overworld", 5, 5, 5, 0, 0);
assertTrue(svc.home(alex, new String[0]).isEmpty());
assertEquals(0, alex.pos.x());
svc.delHome(alex, new String[]{"home"});
svc.delHome(alex, new String[]{"home"});
assertTrue(alex.last().contains("does not exist"));
}
@Test void warpsSeedSpawnAndTeleport() {
assertEquals(List.of("spawn"), svc.warp(alex, new String[0]));
svc.warp(alex, new String[]{"spawn"});
assertEquals(80, alex.pos.y());
svc.warp(alex, new String[]{"nope"});
assertTrue(alex.last().contains("does not exist"));
svc.setWarp(alex, "Arena");
assertTrue(svc.warpAt("arena").isPresent());
svc.delWarp(alex, "arena");
assertTrue(svc.warpAt("arena").isEmpty());
}
@Test void backReturnsToWhereYouWere() {
svc.back(alex);
assertTrue(alex.last().contains("No previous"));
alex.pos = new Pos("overworld", 1, 2, 3, 0, 0);
svc.setWarp(steve, "x"); // unrelated
svc.warp(alex, new String[]{"x"});
svc.back(alex);
assertEquals(1, alex.pos.x());
svc.remember(alex.id, new Pos("overworld", 9, 9, 9, 0, 0)); // death point
svc.back(alex);
assertEquals(9, alex.pos.x());
}
@Test void tpaFlowAndExpiry() {
steve.pos = new Pos("overworld", 100, 64, 100, 0, 0);
svc.tpa(alex, new String[]{"steve"});
assertTrue(steve.last().contains("expires in 60s"));
svc.tpAccept(steve);
assertEquals(100, alex.pos.x());
svc.tpa(alex, new String[]{"steve"});
now.addAndGet(61_000);
svc.tpAccept(steve);
assertTrue(steve.last().contains("no active"));
svc.tpa(alex, new String[]{"alex"});
assertTrue(alex.last().contains("yourself"));
svc.tpa(alex, new String[]{"ghost"});
assertTrue(alex.last().contains("not online"));
svc.tpa(alex, new String[]{"steve"});
svc.tpDeny(steve);
assertTrue(alex.last().contains("denied"));
svc.tpDeny(steve);
assertTrue(steve.last().contains("no active"));
}
@Test void rtpStaysInRadiusAndFailsGracefully() {
svc.configure(new EssentialsConfig(5, 100, 60_000, 15));
svc.rtp(alex);
assertTrue(Math.abs(alex.pos.x()) <= 101 && Math.abs(alex.pos.z()) <= 101);
assertEquals(70, alex.pos.y());
Pos before = alex.pos;
safe = false;
svc.rtp(alex);
assertEquals(before, alex.pos);
assertTrue(alex.last().contains("Could not find"));
}
}
@@ -0,0 +1,85 @@
package net.scopenet.core;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
/** Fakes for the shared-core tests: players, a platform that runs everything inline, and a scriptable panel. */
final class Kit {
static final class Player implements CorePlayer {
final UUID id = UUID.randomUUID();
final String name;
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>();
final Set<String> denied = new HashSet<>();
Item held;
final List<Item> inventory = new ArrayList<>();
long playtime = 3700;
Player(String name) { this.name = name; }
public UUID uuid() { return id; }
public String name() { return name; }
public Pos pos() { return pos; }
public void send(String m) { inbox.add(Format.plain(m)); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return !denied.contains(n); }
public long playtimeSeconds() { return playtime; }
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
public void clearHeld() { held = null; }
public void give(Item i) { inventory.add(i); }
String last() { return inbox.get(inbox.size() - 1); }
boolean heard(String text) { return inbox.stream().anyMatch(s -> s.contains(text)); }
}
static final class FakePlatform implements Platform {
final Map<UUID, Player> online = new LinkedHashMap<>();
boolean safe = true;
Player add(String name) { Player p = new Player(name); online.put(p.id, p); return p; }
public Optional<CorePlayer> player(UUID u) { return Optional.ofNullable(online.get(u)); }
public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); }
public Collection<? extends CorePlayer> online() { return online.values(); }
public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); }
public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); }
public void runMain(Runnable r) { r.run(); }
public void runAsync(Runnable r) { r.run(); }
}
interface Handler { JsonElement handle(JsonObject body) throws Exception; }
static final class FakePanel implements Panel {
final Map<String, Handler> handlers = new HashMap<>();
final List<String> calls = new ArrayList<>();
final List<JsonObject> bodies = new ArrayList<>();
final Map<String, ChunkCheckResult> claims = new HashMap<>();
boolean claimsRefreshed;
public JsonElement call(String endpoint, JsonObject body) throws Exception {
calls.add(endpoint);
bodies.add(body);
Handler h = handlers.get(endpoint);
if (h == null) throw new net.scopenet.integration.PanelClient.HttpFailure(404, "no handler for " + endpoint);
return h.handle(body);
}
public void claimsChanged() { claimsRefreshed = true; }
public ChunkCheckResult check(String dim, int cx, int cz, UUID p) {
return claims.getOrDefault(dim + ":" + cx + ":" + cz, new ChunkCheckResult(false, true, null, null));
}
void on(String endpoint, String json) { handlers.put(endpoint, b -> com.google.gson.JsonParser.parseString(json)); }
}
final FakePlatform platform = new FakePlatform();
final FakePanel panel = new FakePanel();
final AtomicLong now = new AtomicLong(1_000);
final Env env;
Kit(Path dir) {
env = new Env(platform, panel, Features.all(), dir, now::get, new Random(1), Logger.getLogger("test"));
}
static CoreCommand find(List<CoreCommand> all, String name) {
return all.stream().filter(c -> c.name().equals(name)).findFirst().orElseThrow(() -> new AssertionError("no command " + name));
}
}
@@ -0,0 +1,168 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.scopenet.integration.ChunkCheckResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class LinkTest {
static final String INFO = "{\"exists\":true,\"name\":\"Alex\",\"joined\":\"2026-01-02T03:04:05Z\",\"global\":{\"level\":12,\"xp\":5000,\"next_level_xp\":6000,\"current_level_xp\":4800,\"progress_pct\":16.6,\"title\":\"Miner\",\"rank\":3},"
+ "\"server\":{\"level\":4,\"xp\":300,\"progress_pct\":50.0,\"rank_name\":\"Initiate\"},\"guild\":{\"id\":\"g\",\"name\":\"Iron\",\"tag\":\"IRON\",\"role\":\"leader\",\"claims\":3},"
+ "\"balance\":1234.5,\"playtime_secs\":7300,\"server_playtime_secs\":600,\"kills\":10,\"deaths\":4,\"friends\":6,\"achievements\":9,"
+ "\"quests\":{\"daily\":{\"total\":3,\"completed\":1,\"claimed\":0},\"weekly\":{\"total\":2,\"completed\":2,\"claimed\":1}},\"rank\":{\"group\":\"vip\",\"display\":\"VIP\",\"prefix\":\"[VIP]\"}}";
@TempDir Path dir;
Kit kit;
Kit.Player alex;
PlayerCache cache;
ClientLink link;
final List<JsonObject> sent = new ArrayList<>();
@BeforeEach void setup() {
kit = new Kit(dir);
alex = kit.platform.add("Alex");
kit.panel.on("player/info", INFO);
cache = new PlayerCache(kit.env);
link = new ClientLink(kit.env, cache, "SMP", (p, bytes) -> sent.add(JsonParser.parseString(Wire.decode(bytes)).getAsJsonObject()));
}
byte[] msg(String json) { return Wire.encode(json); }
JsonObject last(String type) { for (int i = sent.size() - 1; i >= 0; i--) if (sent.get(i).get("t").getAsString().equals(type)) return sent.get(i); return null; }
@Test void wireRoundTripsAndRejectsGarbage() {
String text = "{\"t\":\"x\",\"u\":\"héllo ☃\"}";
assertEquals(text, Wire.decode(Wire.encode(text)));
assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}));
assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[]{10, 1, 2}));
assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[0]));
// 300-byte message uses a two-byte length.
String big = "x".repeat(300);
assertEquals(302, Wire.encode(big).length);
assertEquals(big, Wire.decode(Wire.encode(big)));
}
@Test void nothingIsSentBeforeHelloAndGarbageIsIgnored() {
link.tick();
link.receive(alex, new byte[]{1, 2, 3});
link.receive(alex, msg("{\"t\":\"market\"}"));
link.receive(alex, msg("[1,2]"));
assertTrue(sent.isEmpty());
assertFalse(link.hasClient(alex.id));
}
@Test void helloSendsFeaturesStateAndClaims() {
kit.panel.claims.put("minecraft:overworld:0:0", new ChunkCheckResult(true, true, "Iron", "IRON"));
kit.panel.claims.put("minecraft:overworld:1:0", new ChunkCheckResult(true, false, "Void", "VOID"));
link.receive(alex, msg("{\"t\":\"hello\",\"mod\":\"0.1\"}"));
JsonObject hello = last("hello");
assertEquals("SMP", hello.get("server").getAsString());
assertTrue(hello.getAsJsonObject("features").get("economy").getAsBoolean());
JsonObject state = last("state");
assertEquals(12, state.get("level").getAsInt());
assertEquals(1234.5, state.get("balance").getAsDouble());
assertEquals("IRON", state.getAsJsonObject("guild").get("tag").getAsString());
assertEquals(2, state.getAsJsonObject("weekly").get("done").getAsInt());
assertEquals("VIP", state.get("rank").getAsString());
JsonObject claims = last("claims");
assertEquals(81, claims.get("cells").getAsString().length());
assertEquals('1', claims.get("cells").getAsString().charAt(4 * 9 + 4), "own chunk in the middle");
assertEquals('2', claims.get("cells").getAsString().charAt(4 * 9 + 5), "someone else's to the east");
assertEquals("IRON", claims.getAsJsonObject("owner").get("tag").getAsString());
assertTrue(claims.getAsJsonObject("owner").get("mine").getAsBoolean());
}
@Test void updatesAreOnlySentWhenSomethingChanged() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
int before = sent.size();
link.tick();
link.tick();
assertEquals(before, sent.size(), "no change, no traffic");
alex.pos = new Pos("minecraft:overworld", 40, 64, 0, 0, 0); // next chunk over
link.tick();
assertEquals(before + 1, sent.size());
assertEquals(2, last("claims").get("cx").getAsInt());
link.disconnected(alex.id);
alex.pos = new Pos("minecraft:overworld", 400, 64, 0, 0, 0);
link.tick();
assertEquals(before + 1, sent.size(), "disconnected players get nothing");
}
@Test void stateRefreshesFromThePanelOnTheCacheClock() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
kit.panel.on("player/info", INFO.replace("\"balance\":1234.5", "\"balance\":99.0"));
kit.now.addAndGet(PlayerCache.REFRESH_MS + 1);
cache.tick(p -> link.pushState(p));
assertEquals(99.0, last("state").get("balance").getAsDouble());
}
@Test void requestsAreThrottledPerPlayer() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
kit.panel.on("economy/market", "[]");
kit.now.addAndGet(1000);
link.receive(alex, msg("{\"t\":\"market\"}"));
for (int i = 0; i < 20; i++) link.receive(alex, msg("{\"t\":\"market\"}"));
assertEquals(1, kit.panel.calls.stream().filter(c -> c.equals("economy/market")).count(), "a burst is one panel call");
kit.now.addAndGet(ClientLink.MIN_REQUEST_GAP_MS + 1);
link.receive(alex, msg("{\"t\":\"market\"}"));
assertEquals(2, kit.panel.calls.stream().filter(c -> c.equals("economy/market")).count());
}
@Test void marketAndShopRequests() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
kit.now.addAndGet(1000);
kit.panel.on("economy/market", "[{\"id\":7,\"seller_name\":\"Mia\",\"item_id\":\"DIAMOND\",\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}]");
link.receive(alex, msg("{\"t\":\"market\"}"));
kit.now.addAndGet(1000);
assertEquals(7, last("market").getAsJsonArray("listings").get(0).getAsJsonObject().get("id").getAsInt());
alex.held = new Item("DIAMOND", "Diamond", 4, "");
kit.now.addAndGet(1000);
link.receive(alex, msg("{\"t\":\"shop\"}"));
JsonObject shop = last("shop");
assertTrue(shop.getAsJsonArray("items").size() > 10);
assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble());
}
@Test void panelEventsBecomeToasts() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
link.onPanelEvents(JsonParser.parseString("[{\"kind\":\"level_up\",\"uuid\":\"" + alex.id + "\",\"data\":{\"scope\":\"global\",\"level\":13}},"
+ "{\"kind\":\"achievement\",\"uuid\":\"" + alex.id + "\",\"data\":{\"title\":\"Deep Diver\"}},"
+ "{\"kind\":\"unknown\",\"uuid\":\"" + alex.id + "\",\"data\":{}},{\"kind\":\"level_up\",\"uuid\":\"nope\"}]").getAsJsonArray());
List<JsonObject> toasts = sent.stream().filter(m -> m.get("t").getAsString().equals("notify")).toList();
assertEquals(2, toasts.size());
assertEquals("Level 13", toasts.get(0).get("text").getAsString());
assertEquals("Deep Diver", toasts.get(1).get("text").getAsString());
}
@Test void disabledClientLinkIsSilent() {
kit.env.features = new Features(true, true, true, true, true, false, "$");
link.receive(alex, msg("{\"t\":\"hello\"}"));
assertTrue(sent.isEmpty());
}
@Test void placeholdersMatchThePaperNames() {
Placeholders ph = new Placeholders(kit.env, cache);
assertEquals("1", ph.resolve(alex.id, "level").orElseThrow(), "before the first answer");
assertEquals("0.00", ph.resolve(alex.id, "balance").orElseThrow());
cache.refresh(alex, null);
Map<String, String> expected = new LinkedHashMap<>();
expected.put("level", "12"); expected.put("xp", "5000"); expected.put("level_progress", "17"); expected.put("title", "Miner");
expected.put("leaderboard_rank", "3"); expected.put("server_level", "4"); expected.put("server_rank", "Initiate");
expected.put("guild", "Iron"); expected.put("guild_tag", "IRON"); expected.put("guild_role", "leader"); expected.put("guild_claims", "3"); expected.put("has_guild", "true");
expected.put("balance", "1234.50"); expected.put("balance_formatted", "$1,234.50"); expected.put("playtime", "2h 1m"); expected.put("playtime_seconds", "7300");
expected.put("playtime_hours", "2"); expected.put("server_playtime", "10m"); expected.put("quest_progress", "3/5"); expected.put("quest_daily", "1/3");
expected.put("quest_weekly", "2/2"); expected.put("kills", "10"); expected.put("deaths", "4"); expected.put("kdr", "2.50"); expected.put("friends", "6");
expected.put("achievements", "9"); expected.put("joined", "2026-01-02"); expected.put("rank", "vip"); expected.put("rank_prefix", "[VIP]");
expected.forEach((k, v) -> assertEquals(v, ph.resolve(alex.id, k).orElseThrow(), k));
assertEquals("§a■■§7■■■■■■■■", ph.resolve(alex.id, "level_bar").orElseThrow());
assertTrue(ph.resolve(alex.id, "nonsense").isEmpty());
for (String k : Placeholders.KEYS) assertTrue(ph.resolve(alex.id, k).isPresent(), k);
assertEquals("NO", "NO".toUpperCase());
}
}
@@ -0,0 +1,46 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
class ActivityTest {
@Test void deltasAndFractionalPlaytimeSurviveSnapshots() {
AtomicLong time = new AtomicLong();
Activity activity = new Activity(time::get);
UUID id = UUID.randomUUID();
activity.join(id, "Steve");
activity.join(id, "Steve");
activity.add(id, "Steve", "blocks_broken", 3);
time.set(1_500_000_000L);
JsonObject first = activity.drain(19.5);
JsonObject row = first.getAsJsonArray("stats").get(0).getAsJsonObject();
assertEquals(1, row.get("joins").getAsInt());
assertEquals(1, row.get("playtime_secs").getAsInt());
assertEquals(3, row.get("blocks_broken").getAsInt());
time.set(2_000_000_000L);
JsonObject second = activity.drain(20);
assertEquals(1, second.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
assertFalse(second.getAsJsonArray("stats").get(0).getAsJsonObject().has("joins"));
assertNotEquals(first.get("batch_id"), second.get("batch_id"));
activity.leave(id, "Steve");
activity.leave(id, "Steve");
JsonObject last = activity.drain(20);
assertEquals(0, last.getAsJsonArray("online").size());
assertEquals(1, last.getAsJsonArray("events").size());
}
@Test void shutdownClearsOnlineAndIncludesLastSessionTime() {
AtomicLong time = new AtomicLong();
Activity activity = new Activity(time::get);
activity.join(UUID.randomUUID(), "Steve");
time.set(3_000_000_000L);
activity.leaveAll();
JsonObject payload = activity.drain(Double.NaN);
assertEquals(0, payload.getAsJsonArray("online").size());
assertEquals(3, payload.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
assertEquals(20, payload.get("tps").getAsInt());
}
}
@@ -0,0 +1,93 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class ClaimIndexTest {
private static final UUID MEMBER = UUID.fromString("b50ad385-829d-3141-a216-7e7d7539ba7f");
private static final UUID OUTSIDER = UUID.randomUUID();
private static final String INDEX = """
{"revision":"7","unchanged":false,
"guilds":[{"id":"g1","name":"Iron Fortress","tag":"IRON"}],
"claims":[["minecraft:overworld",3,-4,0],["minecraft:the_nether",-1,-1,0]],
"members":{"g1":["b50ad385-829d-3141-a216-7e7d7539ba7f","not-a-uuid"]}}""";
private static JsonObject json(String s) { return JsonParser.parseString(s).getAsJsonObject(); }
@Test void answersLocallyAndTreatsWildernessAsOpen() {
ClaimIndex index = new ClaimIndex();
assertFalse(index.loaded());
assertTrue(index.check("minecraft:overworld", 3, -4, OUTSIDER).allowed(), "nothing is enforced before the first load");
assertTrue(index.apply(json(INDEX)));
assertEquals("7", index.revision());
assertEquals(2, index.claimCount());
ChunkCheckResult mine = index.check("minecraft:overworld", 3, -4, MEMBER);
assertTrue(mine.claimed() && mine.allowed());
ChunkCheckResult theirs = index.check("minecraft:overworld", 3, -4, OUTSIDER);
assertTrue(theirs.claimed() && !theirs.allowed());
assertEquals("IRON", theirs.guildTag());
assertFalse(index.check("minecraft:overworld", 3, -3, OUTSIDER).claimed());
assertFalse(index.check("minecraft:the_end", 3, -4, OUTSIDER).claimed(), "claims are per dimension");
assertTrue(index.isClaimed("minecraft:the_nether", -1, -1));
assertFalse(index.isClaimed("minecraft:overworld", -1, -1));
}
@Test void negativeCoordinatesDoNotCollide() {
ClaimIndex index = new ClaimIndex();
index.apply(json("""
{"revision":"1","guilds":[{"id":"g","name":"A","tag":"A"}],
"claims":[["d",-1,0,0],["d",0,-1,0],["d",-1,-1,0]],"members":{}}"""));
assertTrue(index.isClaimed("d", -1, 0));
assertTrue(index.isClaimed("d", 0, -1));
assertTrue(index.isClaimed("d", -1, -1));
assertFalse(index.isClaimed("d", 0, 0));
assertFalse(index.isClaimed("d", 1, -1));
}
@Test void unchangedResponsesKeepTheCurrentCopy() {
ClaimIndex index = new ClaimIndex();
index.apply(json(INDEX));
assertFalse(index.apply(json("{\"revision\":\"7\",\"unchanged\":true}")));
assertEquals(2, index.claimCount());
}
@Test void pollingSendsTheRevisionAndSurvivesPanelOutages() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
AtomicInteger calls = new AtomicInteger();
StringBuilder lastBody = new StringBuilder();
server.createContext("/api/server/v1/guilds/claim-index", exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
lastBody.setLength(0);
lastBody.append(body);
int n = calls.incrementAndGet();
byte[] out = (n == 1 ? INDEX : n == 2 ? "{\"revision\":\"7\",\"unchanged\":true}" : "boom").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(n >= 3 ? 503 : 200, out.length);
exchange.getResponseBody().write(out);
exchange.close();
});
server.start();
try {
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
assertTrue(client.refreshClaims());
assertEquals("{}", lastBody.toString(), "first poll has no revision");
assertFalse(client.refreshClaims());
assertEquals("{\"revision\":\"7\"}", lastBody.toString());
assertThrows(java.io.IOException.class, client::refreshClaims);
// The last good copy still protects land while the panel is down.
assertFalse(client.checkChunk("minecraft:overworld", 3, -4, OUTSIDER).allowed());
assertTrue(client.checkChunk("minecraft:overworld", 3, -4, MEMBER).allowed());
assertTrue(client.isClaimed("minecraft:overworld", 3, -4));
client.close();
} finally {
server.stop(0);
}
}
}
@@ -0,0 +1,106 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class OperationLedgerTest {
private static final String TOKEN = "sn_" + "b".repeat(40);
private HttpServer server;
private PanelClient client;
@TempDir Path dir;
@BeforeEach void start() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), TOKEN));
}
@AfterEach void stop() {
client.close();
server.stop(0);
}
private void reply(String path, int[] statuses, AtomicInteger calls, List<String> seen) {
server.createContext("/api/server/v1/" + path, exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
int n = calls.getAndIncrement();
seen.add(JsonParser.parseString(body).getAsJsonObject().get("operation_id").getAsString());
int status = statuses[Math.min(n, statuses.length - 1)];
byte[] out = (status == 200 ? "{\"ok\":true,\"balance\":42}" : "{\"error\":\"nope\"}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, out.length);
exchange.getResponseBody().write(out);
exchange.close();
});
}
private static JsonObject payload() {
JsonObject p = new JsonObject();
p.addProperty("delta", 5);
return p;
}
@Test void retriesWithTheSameIdUntilThePanelAnswers() throws Exception {
AtomicInteger calls = new AtomicInteger();
List<String> ids = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{503, 503, 200}, calls, ids);
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
try (OperationLedger ledger = new OperationLedger(dir.resolve("pending.json"), client, s -> {}, (id, r) -> results.add(r))) {
String id = ledger.submit("economy/adjust", payload());
JsonObject result = results.poll(15, TimeUnit.SECONDS);
assertNotNull(result);
assertEquals(42, result.get("balance").getAsInt());
assertEquals(3, calls.get());
assertEquals(Set.of(id), new HashSet<>(ids), "every retry carries the same operation id");
assertEquals(0, ledger.size());
}
assertEquals("{}", Files.readString(dir.resolve("pending.json")).trim());
}
@Test void permanentRefusalsAreReportedNotRetried() throws Exception {
AtomicInteger calls = new AtomicInteger();
reply("economy/adjust", new int[]{400}, calls, new CopyOnWriteArrayList<>());
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
try (OperationLedger ledger = new OperationLedger(dir.resolve("pending.json"), client, s -> {}, (id, r) -> results.add(r))) {
ledger.submit("economy/adjust", payload());
JsonObject result = results.poll(10, TimeUnit.SECONDS);
assertNotNull(result);
assertEquals("nope", result.get("error").getAsString());
Thread.sleep(2500);
assertEquals(1, calls.get());
}
}
@Test void pendingOperationsSurviveARestart() throws Exception {
Path file = dir.resolve("pending.json");
AtomicInteger calls = new AtomicInteger();
List<String> ids = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{503}, calls, ids);
String id;
try (OperationLedger first = new OperationLedger(file, client, s -> {}, (i, r) -> {})) {
id = first.submit("economy/adjust", payload());
assertTrue(Files.readString(file).contains(id), "saved before anything is sent");
}
server.removeContext("/api/server/v1/economy/adjust");
AtomicInteger ok = new AtomicInteger();
List<String> resent = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{200}, ok, resent);
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
List<String> logs = new CopyOnWriteArrayList<>();
try (OperationLedger second = new OperationLedger(file, client, logs::add, (i, r) -> results.add(r))) {
assertNotNull(results.poll(10, TimeUnit.SECONDS));
assertEquals(List.of(id), resent);
assertTrue(logs.stream().anyMatch(l -> l.contains("resending 1")));
}
}
}
@@ -0,0 +1,85 @@
package net.scopenet.integration;
import com.google.gson.*;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import java.net.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class PanelClientTest {
private HttpServer server;
private Settings settings;
private final String token = "sn_" + "a".repeat(40);
@BeforeEach void start() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
settings = Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), token);
}
@AfterEach void stop() { server.stop(0); }
private void endpoint(String path, int status, String response, AtomicReference<JsonObject> received) {
server.createContext("/api/server/v1/" + path, exchange -> {
assertEquals("Bearer " + token, exchange.getRequestHeaders().getFirst("Authorization"));
assertEquals("POST", exchange.getRequestMethod());
received.set(JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject());
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
@Test void postsAuthenticatedIdentityAndPreservesDenialMessage() throws Exception {
AtomicReference<JsonObject> received = new AtomicReference<>();
endpoint("login", 200, "{\"allowed\":false,\"message\":\"Account disabled\"}", received);
Integration integration = integration();
UUID id = UUID.randomUUID();
assertEquals("Account disabled", integration.login(id, "Steve", "203.0.113.9").get(2, TimeUnit.SECONDS));
assertEquals(id.toString(), received.get().get("uuid").getAsString());
assertEquals("203.0.113.9", received.get().get("ip").getAsString());
integration.close();
}
@Test void malformedAndHttpFailuresDenyAccess() throws Exception {
endpoint("login", 200, "{}", new AtomicReference<>());
Integration integration = integration();
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
server.removeContext("/api/server/v1/login");
endpoint("login", 503, "{\"allowed\":true}", new AtomicReference<>());
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
integration.close();
}
@Test void onlyExplicitBooleanTrueAllowsLogin() throws Exception {
assertNull(PanelClient.verdict(JsonParser.parseString("{\"allowed\":true}").getAsJsonObject()));
for (String bad : List.of("{}", "{\"allowed\":\"true\"}", "{\"allowed\":null}", "{\"allowed\":1}")) {
assertThrows(IOException.class, () -> PanelClient.verdict(JsonParser.parseString(bad).getAsJsonObject()));
}
}
@Test void redirectsNeverForwardTheServerToken() {
server.createContext("/api/server/v1/login", exchange -> {
exchange.getResponseHeaders().set("Location", "http://127.0.0.1:1/stolen");
exchange.sendResponseHeaders(302, -1);
exchange.close();
});
assertThrows(IOException.class, () -> new PanelClient(settings).post("login", new JsonObject()));
}
@Test void invalidSettingsAndOfflineModeAreRejected() {
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://user:pass@panel.example.com", token));
assertThrows(IllegalArgumentException.class, () -> Settings.of("file:///tmp/panel", token));
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://panel.example.com", ""));
assertThrows(IllegalArgumentException.class, () -> new Integration(settings, "test", "1", "1", false, 20, s -> {}, (id, msg) -> {}));
}
private Integration integration() {
return new Integration(settings, "test", "1.21.1", "test", true, 20, s -> {}, (id, msg) -> {});
}
}
+16
View File
@@ -0,0 +1,16 @@
plugins { id 'fabric-loom' version '1.8.13' }
// Client-only companion to the SCOPENET plugin / Fabric server mod. Fabric 1.20.1.
// It needs nothing from `common`: the messages are plain JSON inside a Minecraft UTF string.
java.sourceCompatibility = JavaVersion.VERSION_17
java.targetCompatibility = JavaVersion.VERSION_17
dependencies {
minecraft 'com.mojang:minecraft:1.20.1'
mappings loom.officialMojangMappings()
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand(version: project.version) }
}
base.archivesName = 'scopenet-client-fabric-1.20.1'
@@ -0,0 +1,105 @@
package net.scopenet.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
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.LinkedHashMap;
import java.util.Map;
/** config/scopenet-client.json: what to show and where. Edited in game (K, then Settings) or by hand. */
public final class ClientConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path FILE = FabricLoader.getInstance().getConfigDir().resolve("scopenet-client.json");
/** Where a widget sits. {@code x}/{@code y} are offsets from the chosen screen anchor. */
public static final class WidgetConfig {
public boolean enabled = true;
public String anchor = "top_left";
public int x = 8;
public int y = 8;
public boolean compact = false;
WidgetConfig(boolean enabled, String anchor, int x, int y) { this.enabled = enabled; this.anchor = anchor; this.x = x; this.y = y; }
public WidgetConfig() { }
}
public static final class Hud {
public float scale = 1.0f;
public float opacity = 0.85f;
public boolean hideInF3 = true;
public boolean hideWhileChatOpen = false;
public boolean claimBorders = true;
public boolean claimBordersOnlyWhenSneaking = false;
public Map<String, WidgetConfig> widgets = defaults();
}
public static final class Notifications {
public boolean levelUp = true;
public boolean achievements = true;
public boolean guild = true;
public boolean errors = true;
}
public boolean enabled = true;
public Hud hud = new Hud();
public Notifications notifications = new Notifications();
public static Map<String, WidgetConfig> defaults() {
Map<String, WidgetConfig> m = new LinkedHashMap<>();
m.put("level", new WidgetConfig(true, "top_left", 8, 8));
m.put("balance", new WidgetConfig(true, "top_right", -8, 8));
m.put("guild", new WidgetConfig(true, "bottom_left", 8, -40));
m.put("claim", new WidgetConfig(true, "top_center", 0, 8));
m.put("quests", new WidgetConfig(false, "middle_right", -8, 0));
return m;
}
public WidgetConfig widget(String id) { return hud.widgets.computeIfAbsent(id, k -> defaults().getOrDefault(k, new WidgetConfig())); }
public static ClientConfig load() {
ClientConfig config = new ClientConfig();
if (Files.exists(FILE)) {
try (Reader in = Files.newBufferedReader(FILE)) {
JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
ClientConfig read = GSON.fromJson(root, ClientConfig.class);
if (read != null) config = read;
} catch (IOException | RuntimeException e) {
System.err.println("[SCOPENET] Could not read " + FILE + ", using defaults: " + e.getMessage());
}
}
// Keep every widget present, and values sane.
Map<String, WidgetConfig> merged = defaults();
if (config.hud == null) config.hud = new Hud();
if (config.hud.widgets != null) merged.putAll(config.hud.widgets);
config.hud.widgets = merged;
if (config.notifications == null) config.notifications = new Notifications();
config.hud.scale = Math.max(0.5f, Math.min(2.0f, config.hud.scale));
config.hud.opacity = Math.max(0.1f, Math.min(1.0f, config.hud.opacity));
return config;
}
public void save() {
try {
Path tmp = FILE.resolveSibling(FILE.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(this, w); }
Files.move(tmp, FILE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
System.err.println("[SCOPENET] Could not save " + FILE + ": " + e.getMessage());
}
}
public void resetHud() {
hud.widgets = defaults();
hud.scale = 1.0f;
hud.opacity = 0.85f;
}
}
@@ -0,0 +1,124 @@
package net.scopenet.client;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.List;
/** What the server last told this client. Everything is optional: before the first message the HUD shows nothing. */
public final class ClientState {
public record Quest(int done, int total) {}
public record Guild(String name, String tag, String role, long claims) {}
public record Owner(String name, String tag, boolean mine) {}
public record Listing(long id, String seller, String guildTag, String itemId, String itemName, int amount, double price) {}
public record ShopItem(String id, String name, double price) {}
public record Held(String id, String name, int count, double unit) {}
public volatile boolean connected;
public volatile String server = "";
public volatile String currency = "$";
public volatile boolean featEconomy, featGuilds, featClaims, featEssentials;
public volatile boolean hasState;
public volatile String name = "";
public volatile long level = 1, xp, xpNow, xpNext, serverLevel = 1;
public volatile double xpPct, serverXpPct, balance;
public volatile String title = "", rank = "";
public volatile Guild guild;
public volatile Quest daily = new Quest(0, 0), weekly = new Quest(0, 0);
public volatile long playtime, kills, deaths;
public volatile String claimDim = "";
public volatile int claimCx, claimCz, claimRadius;
public volatile String claimCells = "";
public volatile Owner claimOwner;
public volatile List<Listing> market = List.of();
public volatile List<ShopItem> shop = List.of();
public volatile Held held;
public volatile long marketAt, shopAt;
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
public String[] handle(String json) {
JsonObject m;
try { m = JsonParser.parseString(json).getAsJsonObject(); } catch (RuntimeException e) { return null; }
switch (str(m, "t")) {
case "hello" -> {
connected = true;
server = str(m, "server");
currency = m.has("currency") ? str(m, "currency") : "$";
JsonObject f = obj(m, "features");
featEconomy = bool(f, "economy"); featGuilds = bool(f, "guilds"); featClaims = bool(f, "claims"); featEssentials = bool(f, "essentials");
}
case "state" -> {
name = str(m, "name");
level = lng(m, "level", 1); xp = lng(m, "xp", 0); xpNow = lng(m, "xpNow", 0); xpNext = lng(m, "xpNext", 0);
xpPct = num(m, "xpPct"); title = str(m, "title"); serverLevel = lng(m, "serverLevel", 1); serverXpPct = num(m, "serverXpPct");
balance = num(m, "balance"); playtime = lng(m, "playtime", 0); kills = lng(m, "kills", 0); deaths = lng(m, "deaths", 0);
rank = str(m, "rank");
JsonObject g = obj(m, "guild");
guild = g == null ? null : new Guild(str(g, "name"), str(g, "tag"), str(g, "role"), lng(g, "claims", 0));
daily = quest(obj(m, "daily"));
weekly = quest(obj(m, "weekly"));
hasState = true;
}
case "claims" -> {
claimDim = str(m, "dim"); claimCx = (int) lng(m, "cx", 0); claimCz = (int) lng(m, "cz", 0);
claimRadius = (int) lng(m, "r", 0); claimCells = str(m, "cells");
JsonObject o = obj(m, "owner");
claimOwner = o == null ? null : new Owner(str(o, "name"), str(o, "tag"), bool(o, "mine"));
}
case "market" -> {
List<Listing> list = new ArrayList<>();
for (JsonElement e : arr(m, "listings")) {
JsonObject l = e.getAsJsonObject();
list.add(new Listing(lng(l, "id", 0), str(l, "seller_name"), l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? str(l, "seller_guild") : null,
str(l, "item_id"), str(l, "item_name"), (int) lng(l, "amount", 1), num(l, "price")));
}
market = list;
if (m.has("balance")) balance = num(m, "balance");
marketAt = System.currentTimeMillis();
}
case "shop" -> {
List<ShopItem> list = new ArrayList<>();
for (JsonElement e : arr(m, "items")) {
JsonObject i = e.getAsJsonObject();
list.add(new ShopItem(str(i, "id"), str(i, "name"), num(i, "price")));
}
shop = list;
JsonObject h = obj(m, "held");
held = h == null ? null : new Held(str(h, "id"), str(h, "name"), (int) lng(h, "count", 0), h.has("unit") ? num(h, "unit") : -1);
shopAt = System.currentTimeMillis();
}
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
default -> { }
}
return null;
}
/** Sample values so the layout editor can show every widget even with no server data. */
public static ClientState preview() {
ClientState s = new ClientState();
s.connected = true; s.hasState = true; s.featEconomy = true; s.featGuilds = true; s.featClaims = true; s.featEssentials = true;
s.level = 27; s.xpPct = 62; s.title = "Miner"; s.serverLevel = 9; s.balance = 12840.5;
s.guild = new Guild("Iron Wolves", "IRON", "officer", 14);
s.daily = new Quest(2, 3); s.weekly = new Quest(1, 4);
s.claimCells = "x"; s.claimOwner = new Owner("Iron Wolves", "IRON", true);
return s;
}
public void reset() {
connected = false; hasState = false; guild = null; claimOwner = null; claimCells = ""; market = List.of(); shop = List.of(); held = null;
}
private static Quest quest(JsonObject o) { return o == null ? new Quest(0, 0) : new Quest((int) lng(o, "done", 0), (int) lng(o, "total", 0)); }
private static String str(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() ? e.getAsString() : ""; }
private static double num(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : 0; }
private static long lng(JsonObject o, String k, long d) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : d; }
private static boolean bool(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsBoolean(); }
private static JsonObject obj(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonObject() ? e.getAsJsonObject() : null; }
private static JsonArray arr(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); }
}
@@ -0,0 +1,59 @@
package net.scopenet.client;
import com.google.gson.JsonObject;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.client.gui.components.toasts.SystemToast;
import net.minecraft.resources.ResourceLocation;
/** The channel to the SCOPENET server mod or plugin. Messages are JSON in a Minecraft UTF string. */
public final class Link {
private static final ResourceLocation TO_CLIENT = new ResourceLocation("scopenet", "s2c");
private static final ResourceLocation TO_SERVER = new ResourceLocation("scopenet", "c2s");
private final ClientState state;
private final ClientConfig config;
Link(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
void register() {
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
String json = buf.readUtf(32767 * 3);
client.execute(() -> toast(state.handle(json)));
});
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
}
public void send(String type) {
JsonObject m = new JsonObject();
m.addProperty("t", type);
if (type.equals("hello")) m.addProperty("mod", "0.1.0");
FriendlyByteBuf buf = PacketByteBufs.create();
buf.writeUtf(m.toString(), 32767 * 3);
try { ClientPlayNetworking.send(TO_SERVER, buf); } catch (RuntimeException ignored) { /* not in a world */ }
}
/** Run a server command as the player, exactly as if typed. */
public static void command(String command) {
Minecraft mc = Minecraft.getInstance();
if (mc.player != null) mc.player.connection.sendCommand(command.startsWith("/") ? command.substring(1) : command);
}
private void toast(String[] t) {
if (t == null) return;
boolean wanted = switch (t[0]) {
case "level_up" -> config.notifications.levelUp;
case "achievement" -> config.notifications.achievements;
case "guild_join", "guild_leave" -> config.notifications.guild;
case "error" -> config.notifications.errors;
default -> true;
};
if (!wanted || !config.enabled) return;
SystemToast.add(Minecraft.getInstance().getToasts(), SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, Component.literal(t[1]), Component.literal(t[2]));
}
}
@@ -0,0 +1,47 @@
package net.scopenet.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
import net.minecraft.client.KeyMapping;
import net.scopenet.client.hud.ClaimBorders;
import net.scopenet.client.hud.Hud;
import net.scopenet.client.screen.MenuScreen;
import org.lwjgl.glfw.GLFW;
/**
* SCOPENET client: HUD widgets, claim borders, and the shop and market windows. Everything it shows comes from the
* server over the {@code scopenet:s2c} channel, and everything it does is an ordinary command, so the server stays
* in charge. If the server has no SCOPENET, this does nothing.
*/
public final class ScopenetClient implements ClientModInitializer {
private static ClientConfig config;
private static ClientState state;
private static Link link;
public static ClientConfig config() { return config; }
public static ClientState state() { return state; }
public static Link link() { return link; }
@Override public void onInitializeClient() {
config = ClientConfig.load();
state = new ClientState();
link = new Link(state, config);
link.register();
Hud hud = new Hud(state, config);
HudRenderCallback.EVENT.register((graphics, tickDelta) -> hud.render(graphics, tickDelta));
ClaimBorders borders = new ClaimBorders(state, config);
WorldRenderEvents.LAST.register(borders::render);
KeyMapping menu = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.menu", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.scopenet"));
KeyMapping toggle = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.toggle_hud", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, "key.categories.scopenet"));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (menu.consumeClick()) if (client.screen == null && client.player != null) client.setScreen(new MenuScreen());
while (toggle.consumeClick()) { config.enabled = !config.enabled; config.save(); }
});
}
}
@@ -0,0 +1,58 @@
package net.scopenet.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import java.util.Locale;
/** Shared look: the SCOPENET palette and a few drawing helpers, so every widget and window feels like one app. */
public final class Ui {
public static final int ACCENT = 0xFF8B6CFF, CYAN = 0xFF22D3EE, GOLD = 0xFFFBBF24, GREEN = 0xFF34D399, RED = 0xFFFB7185;
public static final int TEXT = 0xFFE6E8EE, MUTED = 0xFF9AA0AE, PANEL = 0x0E1014, PANEL_2 = 0x1A1D25, LINE = 0xFF2B2F3A;
private Ui() {}
public static int alpha(int rgb, float opacity) {
return (Math.max(0, Math.min(255, (int) (opacity * 255))) << 24) | (rgb & 0xFFFFFF);
}
public static String money(String symbol, double value) { return symbol + String.format(Locale.US, "%,.2f", value); }
/** A card: dark rounded-looking panel with a coloured edge on the left. */
public static void card(GuiGraphics g, int x, int y, int w, int h, int accent, float opacity) {
g.fill(x + 1, y, x + w - 1, y + h, alpha(PANEL, opacity));
g.fill(x, y + 1, x + w, y + h - 1, alpha(PANEL, opacity));
g.fill(x, y + 1, x + 2, y + h - 1, accent);
}
/** A flat panel with a 1px border, for windows. */
public static void window(GuiGraphics g, int x, int y, int w, int h) {
g.fill(x - 1, y - 1, x + w + 1, y + h + 1, LINE);
g.fill(x, y, x + w, y + h, 0xF0101319);
}
public static void bar(GuiGraphics g, int x, int y, int w, int h, double pct, int colour, float opacity) {
g.fill(x, y, x + w, y + h, alpha(0x2B2F3A, Math.max(opacity, 0.6f)));
int filled = (int) Math.round(w * Math.max(0, Math.min(100, pct)) / 100.0);
if (filled > 0) g.fill(x, y, x + filled, y + h, colour);
}
/** The item for a panel id ("DIAMOND" or "modid:item"); barrier if the client doesn't know it. */
public static net.minecraft.world.item.ItemStack stackFor(String panelId, int count) {
String registry = panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(Locale.ROOT);
net.minecraft.resources.ResourceLocation id = net.minecraft.resources.ResourceLocation.tryParse(registry);
net.minecraft.world.item.Item item = id == null ? net.minecraft.world.item.Items.AIR : net.minecraft.core.registries.BuiltInRegistries.ITEM.get(id);
if (item == net.minecraft.world.item.Items.AIR) item = net.minecraft.world.item.Items.BARRIER;
return new net.minecraft.world.item.ItemStack(item, Math.max(1, count));
}
public static boolean inside(double mx, double my, int x, int y, int w, int h) { return mx >= x && mx < x + w && my >= y && my < y + h; }
public static String clip(Font font, String text, int maxWidth) {
if (font.width(text) <= maxWidth) return text;
String ellipsis = "…";
int end = text.length();
while (end > 0 && font.width(text.substring(0, end) + ellipsis) > maxWidth) end--;
return text.substring(0, end) + ellipsis;
}
}
@@ -0,0 +1,49 @@
package net.scopenet.client.hud;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.world.phys.Vec3;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** Draws the borders of claimed chunks around you in the world: green for your guild's land, red for anyone else's. */
public final class ClaimBorders {
private final ClientState state;
private final ClientConfig config;
public ClaimBorders(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
public void render(WorldRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !config.hud.claimBorders || !state.connected || !state.featClaims || state.claimCells.isEmpty() || mc.player == null) return;
if (config.hud.claimBordersOnlyWhenSneaking && !mc.player.isShiftKeyDown()) return;
if (context.consumers() == null) return;
// Only the world the server sent the map for.
String dim = mc.level == null ? "" : mc.level.dimension().location().toString();
if (!dim.equals(state.claimDim)) return;
int r = state.claimRadius, side = r * 2 + 1;
String cells = state.claimCells;
if (cells.length() != side * side) return;
Vec3 cam = context.camera().getPosition();
PoseStack pose = context.matrixStack();
VertexConsumer lines = context.consumers().getBuffer(RenderType.lines());
double y0 = Math.floor(mc.player.getY()) - 6, y1 = y0 + 14;
pose.pushPose();
pose.translate(-cam.x, -cam.y, -cam.z);
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
char cell = cells.charAt((dz + r) * side + (dx + r));
if (cell == '0') continue;
float cr = cell == '1' ? 0.2f : 0.98f, cg = cell == '1' ? 0.85f : 0.35f, cb = cell == '1' ? 0.55f : 0.45f;
double x0 = (state.claimCx + dx) * 16.0, z0 = (state.claimCz + dz) * 16.0;
LevelRenderer.renderLineBox(pose, lines, x0 + 0.02, y0, z0 + 0.02, x0 + 15.98, y1, z0 + 15.98, cr, cg, cb, 0.9f);
}
}
pose.popPose();
}
}
@@ -0,0 +1,60 @@
package net.scopenet.client.hud;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.ChatScreen;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
/** Lays out and draws the widgets. The editor uses the same maths, so what you place is what you get. */
public final class Hud {
public static final String[] ANCHORS = {"top_left", "top_center", "top_right", "middle_left", "middle_center", "middle_right", "bottom_left", "bottom_center", "bottom_right"};
private final ClientState state;
private final ClientConfig config;
public Hud(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
/** Top-left corner for a widget of size (w, h) on a screen of (sw, sh) (in HUD units, i.e. already divided by the scale). */
public static int[] place(WidgetConfig c, int sw, int sh, int w, int h) {
String a = c.anchor == null ? "top_left" : c.anchor;
int x = a.endsWith("right") ? sw - w : a.endsWith("center") ? (sw - w) / 2 : 0;
int y = a.startsWith("bottom") ? sh - h : a.startsWith("middle") ? (sh - h) / 2 : 0;
return new int[]{x + c.x, y + c.y};
}
/** The anchor nearest to a spot, and the offsets from it, so dragged widgets keep their place on any window size. */
public static void anchorTo(WidgetConfig c, int sw, int sh, int w, int h, int left, int top) {
double cx = left + w / 2.0, cy = top + h / 2.0;
String col = cx < sw / 3.0 ? "left" : cx > sw * 2 / 3.0 ? "right" : "center";
String row = cy < sh / 3.0 ? "top" : cy > sh * 2 / 3.0 ? "bottom" : "middle";
String anchor = row + "_" + col;
c.anchor = anchor;
int bx = anchor.endsWith("right") ? sw - w : anchor.endsWith("center") ? (sw - w) / 2 : 0;
int by = anchor.startsWith("bottom") ? sh - h : anchor.startsWith("middle") ? (sh - h) / 2 : 0;
c.x = left - bx;
c.y = top - by;
}
public void render(GuiGraphics g, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !state.connected || mc.options.hideGui || mc.player == null) return;
if (config.hud.hideInF3 && mc.options.renderDebug) return;
if (config.hud.hideWhileChatOpen && mc.screen instanceof ChatScreen) return;
Font font = mc.font;
float scale = config.hud.scale;
int sw = (int) (g.guiWidth() / scale), sh = (int) (g.guiHeight() / scale);
g.pose().pushPose();
g.pose().scale(scale, scale, 1f);
for (HudWidget widget : Widgets.ALL) {
WidgetConfig c = config.widget(widget.id);
if (!c.enabled || !widget.available(state)) continue;
int w = widget.width(font, state, c), h = widget.height(font, state, c);
int[] at = place(c, sw, sh, w, h);
widget.draw(g, font, state, c, at[0], at[1], config.hud.opacity);
}
g.pose().popPose();
}
}
@@ -0,0 +1,24 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** One draggable piece of the HUD. */
public abstract class HudWidget {
public final String id;
public final String label;
protected HudWidget(String id, String label) { this.id = id; this.label = label; }
/** False when the server says this system is off, or there's nothing to show. The editor ignores this. */
public abstract boolean available(ClientState s);
public abstract int width(Font font, ClientState s, ClientConfig.WidgetConfig c);
public abstract int height(Font font, ClientState s, ClientConfig.WidgetConfig c);
/** Draw with the top-left corner at (x, y). */
public abstract void draw(GuiGraphics g, Font font, ClientState s, ClientConfig.WidgetConfig c, int x, int y, float opacity);
}
@@ -0,0 +1,109 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
import net.scopenet.client.Ui;
import java.util.List;
/** The built-in HUD widgets: level, balance, guild, claim and quests. */
public final class Widgets {
private Widgets() {}
public static final List<HudWidget> ALL = List.of(new Level(), new Balance(), new Guild(), new Claim(), new Quests());
static final class Level extends HudWidget {
Level() { super("level", "Level"); }
@Override public boolean available(ClientState s) { return s.hasState; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 64 : 118; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 22 : 34; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.ACCENT, o);
g.drawString(f, "Lv " + s.level, x + 7, y + 5, Ui.TEXT, true);
if (!c.compact) {
String title = s.title.isEmpty() ? "" : Ui.clip(f, s.title, w - 52);
g.drawString(f, title, x + w - 6 - f.width(title), y + 5, Ui.MUTED, true);
Ui.bar(g, x + 7, y + 18, w - 14, 5, s.xpPct, Ui.ACCENT, o);
String text = Math.round(s.xpPct) + "% · SMP Lv " + s.serverLevel;
g.drawString(f, text, x + 7, y + 25, Ui.MUTED, false);
} else {
Ui.bar(g, x + 7, y + 15, w - 14, 4, s.xpPct, Ui.ACCENT, o);
}
}
}
static final class Balance extends HudWidget {
Balance() { super("balance", "Balance"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featEconomy; }
private String text(ClientState s) { return Ui.money(s.currency, s.balance); }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return Math.max(c.compact ? 50 : 76, f.width(text(s)) + 18); }
@Override public int height(Font font, ClientState s, WidgetConfig c) { return c.compact ? 18 : 28; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.GOLD, o);
if (!c.compact) {
g.drawString(f, "BALANCE", x + 7, y + 4, Ui.MUTED, false);
g.drawString(f, text(s), x + 7, y + 15, Ui.GOLD, true);
} else {
g.drawString(f, text(s), x + 7, y + 5, Ui.GOLD, true);
}
}
}
static final class Guild extends HudWidget {
Guild() { super("guild", "Guild"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featGuilds && s.guild != null; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 86 : 124; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 18 : 30; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
ClientState.Guild guild = s.guild;
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.CYAN, o);
String head = "[" + guild.tag() + "] " + guild.name();
g.drawString(f, Ui.clip(f, head, w - 14), x + 7, y + 4, Ui.TEXT, true);
if (!c.compact) g.drawString(f, guild.role() + " · " + guild.claims() + " chunks", x + 7, y + 17, Ui.MUTED, false);
}
}
static final class Claim extends HudWidget {
Claim() { super("claim", "Claim"); }
@Override public boolean available(ClientState s) { return s.featClaims && !s.claimCells.isEmpty(); }
private String text(ClientState s) {
ClientState.Owner o = s.claimOwner;
if (o == null) return "Wilderness";
String name = o.tag().isEmpty() ? o.name() : "[" + o.tag() + "] " + o.name();
return (o.mine() ? "Your land: " : "Claimed: ") + name;
}
@Override public int width(Font f, ClientState s, WidgetConfig c) { return f.width(text(s)) + 18; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 18; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
ClientState.Owner owner = s.claimOwner;
int colour = owner == null ? Ui.MUTED : owner.mine() ? Ui.GREEN : Ui.RED;
Ui.card(g, x, y, w, 18, colour, o);
g.drawString(f, text(s), x + 9, y + 5, colour == Ui.MUTED ? Ui.TEXT : colour, true);
}
}
static final class Quests extends HudWidget {
Quests() { super("quests", "Quests"); }
@Override public boolean available(ClientState s) { return s.hasState && (s.daily.total() + s.weekly.total()) > 0; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return 96; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 38; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
Ui.card(g, x, y, w, 38, Ui.GREEN, o);
row(g, f, "Daily", s.daily, x + 7, y + 4, w - 14);
row(g, f, "Weekly", s.weekly, x + 7, y + 21, w - 14);
}
private void row(GuiGraphics g, Font f, String label, ClientState.Quest q, int x, int y, int w) {
g.drawString(f, label, x, y, Ui.MUTED, false);
String n = q.done() + "/" + q.total();
g.drawString(f, n, x + w - f.width(n), y, Ui.TEXT, true);
Ui.bar(g, x, y + 10, w, 3, q.total() == 0 ? 0 : 100.0 * q.done() / q.total(), Ui.GREEN, 1f);
}
}
}
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more