57 Commits
Author SHA1 Message Date
SCOPEDD 7a1f3431ae Merge pull request #3 from scopeddlol/codex/complete-private-auth-integrations
CI / Rust (fmt, clippy, tests) (push) Failing after 2m58s
CI / Web UIs (type-check + build) (launcher) (push) Successful in 1m16s
CI / Web UIs (type-check + build) (panel/web) (push) Successful in 1m13s
CI / Launcher (Windows build check) (push) Canceled after 0s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Canceled after 10m26s
CI / Docker image builds (push) Canceled after 6m26s
Feature Update: Progression (Global & Server Levels), Quests, Achievements, Guilds Land Claiming, and Social Systems
2026-09-30 19:33:29 -04:00
scoped 6a2944a88e Rework BlueMap claiming and expand ranks guilds chat and warps 2026-09-30 18:09:18 -04:00
scoped 434ed5338a Fix Paper startup before worlds load and add 0.5.0 artifacts 2026-09-30 16:58:45 -04:00
Claude c0daa0507a Admin server page: open map link; docs pointers 2026-09-30 20:25:48 +00:00
Claude 228727a395 BlueMap docs, command guide entries, early BlueMap hook on Fabric 2026-09-30 20:24:42 +00:00
Claude 2817ad2ac5 BlueMap addon, shop points, map actions wired into Paper and Fabric; client opens shop windows 2026-09-30 20:24:02 +00:00
Claude ad7285efac Map core: claim outlines, pins, shop points, action poller, guild invite commands 2026-09-30 20:19:48 +00:00
Claude e160ad8da3 Launcher: BlueMap view with player side panel, map actions, guild invitations 2026-09-30 20:15:32 +00:00
Claude 878827625a Panel: server map address, map actions queue, guild invitations 2026-09-30 20:13:44 +00:00
Claude eba2ccfdab Launcher: scrollable command guide, profile tabs fit without scrolling, confirm before removing a friend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 20:07:15 +00:00
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 1954448219 Merge pull request #2 from scopeddlol/codex/complete-private-auth-integrations
Complete private authentication, server integrations and activity reporting
2026-09-28 13:59:38 -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
351 changed files with 44357 additions and 851 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ target
.github
data
docs
/release-artifacts
launcher/src
launcher/src-tauri/icons
launcher/src-tauri/gen
+8 -2
View File
@@ -1,8 +1,14 @@
# Copy to .env and adjust. Only ADMIN_PASSWORD really matters.
# Copy to .env and adjust. If ADMIN_PASSWORD is blank, a random first-start
# password is printed in `docker compose logs panel`.
PANEL_PORT=8080
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-please
ADMIN_PASSWORD=
JWT_SECRET=
CURSEFORGE_API_KEY=
MAX_UPLOAD_MB=2048
RUST_LOG=info
PUBLIC_URL=
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
SCOPENET_TRUSTED_PROXIES=
PANEL_MEMORY=256M
+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-to: type=gha,mode=max
server-integrations:
needs: version
uses: ./.github/workflows/integrations.yml
with:
version: ${{ needs.version.outputs.version }}
release:
name: GitHub release
needs: [version, launcher, panel-image]
needs: [version, launcher, panel-image, server-integrations]
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
name: windows-installer
path: dist
- uses: actions/download-artifact@v4
with:
pattern: server-*
merge-multiple: true
path: dist
- name: Release notes
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
@@ -148,6 +159,8 @@ jobs:
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
**Admin panel (Docker):**
\`\`\`bash
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
@@ -162,4 +175,6 @@ jobs:
body_path: notes.md
generate_release_notes: true
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
launcher/src-tauri/gen/schemas
.DS_Store
/integrations/.gradle/
/integrations/**/.gradle/
/integrations/**/build/
/integrations/**/run/
/integrations/dist/
Generated
+298 -5
View File
@@ -279,6 +279,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -298,8 +299,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -478,6 +481,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -698,6 +707,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie"
version = "0.18.2"
@@ -904,6 +919,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -946,6 +967,17 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -994,6 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
@@ -1131,6 +1164,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
dependencies = [
"base64 0.23.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embed-resource"
version = "3.0.11"
@@ -2108,6 +2157,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2378,6 +2440,36 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "lettre"
version = "0.11.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
dependencies = [
"async-trait",
"base64 0.23.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "libappindicator"
@@ -2428,6 +2520,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.25"
@@ -2579,6 +2677,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.20.0"
@@ -2659,6 +2767,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2678,6 +2795,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.8",
"smallvec",
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2693,6 +2826,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2700,6 +2843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -3035,6 +3179,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3111,6 +3264,27 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -3288,6 +3462,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -3362,6 +3542,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -3381,10 +3567,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -3406,6 +3602,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3415,6 +3621,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -3523,6 +3738,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3618,6 +3834,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha1",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3652,6 +3889,7 @@ version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3761,9 +3999,10 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scopenet-core"
version = "0.1.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"fastnbt",
"futures",
"hex",
@@ -3772,6 +4011,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"sha2",
"tempfile",
"tokio",
"tracing",
@@ -3781,7 +4021,7 @@ dependencies = [
[[package]]
name = "scopenet-launcher"
version = "0.1.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -3807,23 +4047,28 @@ dependencies = [
[[package]]
name = "scopenet-panel"
version = "0.1.0"
version = "0.5.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"base64 0.22.1",
"chrono",
"futures",
"hex",
"image",
"jsonwebtoken",
"lettre",
"percent-encoding",
"rand 0.8.8",
"reqwest 0.12.28",
"rsa",
"scopenet-core",
"scopenet-shared",
"serde",
"serde_json",
"sha1",
"sha2",
"sqlx",
"tempfile",
"thiserror 2.0.21",
@@ -3838,7 +4083,7 @@ dependencies = [
[[package]]
name = "scopenet-shared"
version = "0.1.0"
version = "0.5.0"
dependencies = [
"md-5",
"serde",
@@ -4149,6 +4394,16 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -4258,6 +4513,16 @@ dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
@@ -5011,6 +5276,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -5268,6 +5545,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "typeid"
version = "1.0.3"
+6 -2
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package]
version = "0.1.0"
version = "0.5.0"
edition = "2021"
repository = "https://github.com/scopeddlol/SCOPENET-MC"
@@ -17,7 +17,8 @@ serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
futures = "0.3"
sha1 = "0.10"
sha1 = { version = "0.10", features = ["oid"] }
sha2 = "0.10"
md-5 = "0.10"
hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
@@ -37,3 +38,6 @@ strip = true
[profile.dev.package."*"]
opt-level = 1
# Dependencies rarely need a debugger; skipping their debug info keeps
# target/ several times smaller.
debug = false
+5 -5
View File
@@ -18,11 +18,11 @@ ARG TARGETARCH
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets.
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild
RUN case "$TARGETARCH" in \
amd64) echo x86_64-unknown-linux-musl ;; \
arm64) echo aarch64-unknown-linux-musl ;; \
*) echo "unsupported arch $TARGETARCH" >&2; exit 1 ;; \
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild ziglang
RUN case "${TARGETARCH:-$(uname -m)}" in \
amd64|x86_64) echo x86_64-unknown-linux-musl ;; \
arm64|aarch64) echo aarch64-unknown-linux-musl ;; \
*) echo "unsupported arch ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
esac > /rust-target \
&& rustup target add "$(cat /rust-target)"
WORKDIR /src
+13 -8
View File
@@ -25,7 +25,7 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
### Launcher features
- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager.
- **Accounts** — private panel accounts (username + password, authenticated online-mode servers), and local **offline** usernames for local play. Tokens are encrypted with a key stored in Windows Credential Manager.
- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players.
- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
@@ -52,10 +52,12 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
### 1. Run the panel
```bash
curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example
# edit .env → set ADMIN_PASSWORD
docker compose up -d
git clone https://github.com/scopeddlol/SCOPENET-MC.git
cd SCOPENET-MC
cp .env.example .env
# edit .env → set ADMIN_PASSWORD and PUBLIC_URL for your domain
docker compose config --quiet
docker compose up -d --build
```
Open `http://your-server:8080`, sign in as `admin`, then:
@@ -64,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.
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
@@ -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/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, authlib, ping
integrations/ Paper plugin and Fabric/Forge server mods
panel/server Admin panel API (Axum + SQLite)
panel/web Admin panel UI (Svelte 5)
launcher/ Desktop launcher UI (Svelte 5)
@@ -95,9 +98,11 @@ docker-compose.yml Panel deployment
## Documentation
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
- [Development](docs/development.md) — running everything locally, tests
- [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups
- [Local 0.4.0 artifacts](release-artifacts/0.4.0/README.md) — Windows installer, server jars and checksums
## Configuration reference
+2
View File
@@ -13,6 +13,8 @@ tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
sha2.workspace = true
base64.workspace = true
hex.workspace = true
zip.workspace = true
tracing.workspace = true
+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.
pub uuid: String,
pub access_token: String,
/// "msa" for Microsoft accounts, "legacy" for offline.
/// "mojang" for panel (Yggdrasil) accounts, "legacy" for offline.
pub user_type: String,
pub xuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub auth: Auth,
/// JVM arguments that must come first (the authlib-injector agent).
pub agent_args: Vec<String>,
pub game_dir: PathBuf,
pub memory_min_mb: u32,
pub memory_max_mb: u32,
@@ -187,7 +188,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
vars.insert("auth_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new());
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
vars.insert("auth_xuid", String::new());
vars.insert("user_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone());
@@ -203,7 +204,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
}
let mut args = Vec::new();
let mut args = opts.agent_args.clone();
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major));
@@ -336,8 +337,8 @@ mod tests {
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
game_dir: "/g".into(),
memory_min_mb: 1024,
memory_max_mb: 4096,
@@ -362,7 +363,7 @@ mod tests {
let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" ");
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
assert!(a.starts_with("-javaagent:/a.jar=https://p/api/yggdrasil -Xms1024M -Xmx4096M"), "agent must come first");
assert!(a.contains("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720"));
+1 -1
View File
@@ -5,6 +5,7 @@
//! tested without a window.
pub mod assets;
pub mod authlib;
pub mod http;
pub mod install;
pub mod java;
@@ -13,7 +14,6 @@ pub mod libraries;
pub mod loaders;
pub mod maven;
pub mod meta;
pub mod msa;
pub mod options;
pub mod paths;
pub mod ping;
-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::path::Path;
/// Player-owned vanilla preferences. Pack-managed options.txt may be replaced
/// during sync, so these are captured separately and merged back afterward.
const VANILLA_KEYS: &[&str] = &[
"fov", "gamma", "graphicsMode", "fancyGraphics", "renderDistance", "simulationDistance",
"maxFps", "enableVsync", "fullscreen", "fullscreenResolution", "guiScale", "particles",
"clouds", "renderClouds", "entityShadows", "entityDistanceScaling", "biomeBlendRadius",
"mipmapLevels", "ao", "ambientOcclusion", "bobView", "attackIndicator", "autoJump",
"mouseSensitivity", "invertYMouse", "discrete_mouse_scroll", "mouseWheelSensitivity",
"rawMouseInput", "toggleCrouch", "toggleSprint", "sneakToggled", "sprintToggled",
"screenEffectScale", "fovEffectScale", "darknessEffectScale", "narrator", "subtitles",
"chatVisibility", "chatColors", "chatLinks", "chatLinksPrompt", "chatOpacity", "textBackgroundOpacity",
"backgroundForChatOnly", "chatScale", "chatWidth", "chatHeightFocused", "chatHeightUnfocused",
"mainHand", "lang", "forceUnicodeFont", "tutorialStep", "hideBundleTutorial",
"soundDevice", "directionalAudio", "musicFrequency", "notificationDisplayTime",
"joinedFirstServer", "pauseOnLostFocus", "reducedDebugInfo", "showSubtitles",
];
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
let path = game_dir.join("options.txt");
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.into()),
};
Ok(contents.lines().filter_map(|line| {
let (key, value) = line.split_once(':')?;
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
}).collect())
}
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
@@ -51,4 +81,16 @@ mod tests {
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
}
#[test]
fn captures_only_vanilla_preferences() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n").unwrap();
let prefs = read_vanilla_preferences(dir.path()).unwrap();
assert_eq!(prefs.get("fov").map(String::as_str), Some("0.8"));
assert_eq!(prefs.get("autoJump").map(String::as_str), Some("false"));
assert_eq!(prefs.get("soundCategory_music").map(String::as_str), Some("0.25"));
assert!(!prefs.contains_key("modded"));
assert!(!prefs.contains_key("key_key.forward"));
}
}
+12 -1
View File
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
uuid: scopenet_shared::offline_uuid("CiBot"),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
agent_args: vec![],
game_dir,
memory_min_mb: 512,
memory_max_mb: 2048,
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
async fn neoforge_1_21_1() {
run("1.21.1", Loader::NeoForge).await;
}
#[tokio::test]
#[ignore]
async fn authlib_injector_official_download() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path());
let jar = scopenet_core::authlib::ensure(&scopenet_core::http::client(), &layout, None).await.unwrap();
let zip = zip::ZipArchive::new(std::fs::File::open(&jar).unwrap()).unwrap();
assert!(zip.file_names().any(|n| n == "META-INF/MANIFEST.MF"), "not a jar: {}", jar.display());
println!("authlib-injector: {}", jar.display());
}
+446 -10
View File
@@ -31,6 +31,7 @@ pub struct Branding {
pub version_label: String,
pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>,
pub about: AboutContent,
pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String,
@@ -60,12 +61,22 @@ impl Default for Branding {
tag: Some("News".into()),
}],
links: vec![],
about: AboutContent::default(),
features: Features::default(),
custom_css: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AboutContent {
pub title: String,
pub icon_url: Option<String>,
pub body: String,
pub links: Vec<SocialLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Background {
@@ -175,23 +186,72 @@ pub enum RegistrationMode {
pub struct AuthConfig {
pub panel_accounts: bool,
pub registration: RegistrationMode,
pub microsoft: bool,
pub microsoft_client_id: Option<String>,
pub offline_local: bool,
/// Absolute URL of the panel's Yggdrasil (authlib-injector) API root.
/// Filled in by the panel; the launcher falls back to `{panel}/api/yggdrasil`.
pub yggdrasil_url: Option<String>,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
panel_accounts: true,
registration: RegistrationMode::Closed,
microsoft: false,
microsoft_client_id: None,
offline_local: true,
}
Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
}
}
/// Yggdrasil session tokens handed to the launcher at sign-in. The access
/// token is what the game (via authlib-injector) uses to join servers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct YggdrasilTokens {
pub access_token: String,
pub client_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct CapeInfo {
pub id: i64,
pub name: String,
pub url: String,
}
/// A player's in-game identity: UUID, skin and cape.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct PlayerProfile {
pub uuid: String,
pub name: String,
pub skin_url: Option<String>,
/// "classic" (Steve arms) or "slim" (Alex arms).
pub skin_model: String,
pub cape: Option<CapeInfo>,
/// Capes this player is allowed to pick.
pub available_capes: Vec<CapeInfo>,
}
/// A saved skin and cape preset (wardrobe look).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkinProfile {
pub id: String,
pub name: String,
pub skin_data: Option<String>,
#[serde(default = "default_skin_model")]
pub skin_model: String,
#[serde(default)]
pub cape_id: Option<i64>,
#[serde(default)]
pub cape_name: Option<String>,
#[serde(default)]
pub cape_url: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub created_at: String,
}
fn default_skin_model() -> String {
"classic".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
@@ -210,7 +270,8 @@ pub struct RegisterRequest {
pub struct PublicUser {
pub id: i64,
pub username: String,
/// Offline-mode UUID (dashed), identical to what an offline server computes.
/// The player's UUID (dashed). New accounts get the offline-mode UUID for
/// their name, so offline and authenticated servers agree.
pub uuid: String,
pub role: String,
pub groups: Vec<String>,
@@ -218,11 +279,15 @@ pub struct PublicUser {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
/// Panel session token (manifest, skins, account API).
pub token: String,
pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval.
#[serde(default)]
pub pending: bool,
/// Game session for authlib-injector; absent for pending accounts.
#[serde(default)]
pub yggdrasil: Option<YggdrasilTokens>,
}
// ---------------------------------------------------------------------------
@@ -302,6 +367,7 @@ pub struct InstanceSummary {
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub logo_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
@@ -354,6 +420,376 @@ pub struct LaunchEvent {
pub username: Option<String>,
}
// ---------------------------------------------------------------------------
// Leveling & Quests
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserLevelInfo {
pub uuid: String,
pub global_xp: i64,
#[serde(default)]
pub current_xp: i64,
pub global_level: i64,
#[serde(default)]
pub level: i64,
pub current_level_xp: i64,
pub next_level_xp: i64,
pub progress_pct: f64,
pub title: Option<String>,
pub badges: Vec<String>,
pub server_levels: Vec<ServerLevelInfo>,
#[serde(default)]
pub rank: Option<usize>,
#[serde(default)]
pub rewards: Vec<LevelReward>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct LevelReward {
pub id: i64,
pub level: i64,
pub reward_type: String,
pub reward_value: String,
pub description: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub server_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ServerLevelInfo {
pub server_id: i64,
pub server_name: String,
pub server_xp: i64,
pub server_level: i64,
pub current_level_xp: i64,
pub next_level_xp: i64,
pub progress_pct: f64,
pub rank_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Quest {
pub id: String,
pub title: String,
pub description: String,
pub period: String, // "daily", "weekly"
pub category: String, // "mining", "combat", "building", "farming", "social", "exploration"
pub target_stat: String,
pub target_count: i64,
pub xp_reward: i64,
pub icon: String,
pub enabled: bool,
#[serde(default)]
pub quest_type: Option<String>,
#[serde(default)]
pub stat_type: Option<String>,
#[serde(default)]
pub active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserQuest {
pub quest: Quest,
#[serde(default)]
pub progress: i64,
#[serde(default)]
pub current_count: i64,
pub completed: bool,
pub claimed: bool,
pub period_key: String,
#[serde(default)]
pub expires_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Achievements
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Achievement {
pub id: String,
pub title: String,
pub description: String,
pub category: String,
pub icon_frame: String, // "task", "goal", "challenge"
#[serde(default)]
pub frame_type: Option<String>,
pub icon_item: String,
pub icon_bg: String,
pub icon_border: String,
pub xp_reward: i64,
pub secret: bool,
#[serde(default)]
pub stat_type: Option<String>,
#[serde(default)]
pub target_count: Option<i64>,
#[serde(default)]
pub unlocked: bool,
#[serde(default)]
pub unlocked_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ServerEconomyBalance {
pub server_id: i64,
pub server_name: String,
pub balance: f64,
pub currency_symbol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct EconomyTransaction {
pub id: i64,
pub server_id: i64,
#[serde(default)]
pub server_name: Option<String>,
pub from_uuid: String,
pub from_name: String,
pub to_uuid: String,
pub to_name: String,
pub amount: f64,
pub description: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct BaltopEntry {
pub rank: usize,
pub uuid: String,
pub username: String,
pub balance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MarketListing {
pub id: i64,
pub server_id: i64,
pub seller_uuid: String,
pub seller_name: String,
pub item_id: String,
pub item_name: String,
pub amount: i32,
pub price: f64,
pub created_at: String,
/// Set when a guild is selling; the sale is paid into its bank.
#[serde(default)]
pub seller_guild_tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MemberProfile {
pub uuid: String,
pub username: String,
pub role: String,
pub status: String,
pub skin_url: Option<String>,
pub global_level: i64,
pub title: Option<String>,
pub playtime_secs: i64,
pub last_seen: Option<String>,
pub online: bool,
pub is_friend: bool,
/// `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
#[serde(default)]
pub friendship_status: String,
}
// ---------------------------------------------------------------------------
// Guilds & Land Claiming
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Guild {
pub id: String,
pub instance_id: String,
pub name: String,
pub tag: String,
pub description: String,
pub motd: String,
pub leader_uuid: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub level: i64,
pub xp: i64,
pub max_claims: i64,
pub member_count: i64,
pub claims_count: i64,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GuildMember {
pub uuid: String,
pub name: String,
pub role: String, // "leader", "officer", "member", "recruit"
pub joined_at: String,
#[serde(default)]
pub online: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GuildPost {
pub id: i64,
pub guild_id: String,
pub author_uuid: String,
pub author_name: String,
pub title: String,
pub content: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GuildClaim {
pub id: i64,
pub guild_id: String,
pub guild_name: String,
pub guild_tag: String,
pub server_id: i64,
pub dimension: String,
pub chunk_x: i32,
pub chunk_z: i32,
pub claimed_by_uuid: String,
pub claimed_at: String,
}
// ---------------------------------------------------------------------------
// Social: Friends, DMs, Invites, Profiles & Posts
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct FriendInfo {
pub uuid: String,
pub username: String,
pub status: String, // "accepted", "pending_incoming", "pending_outgoing"
pub online: bool,
pub playing_on: Option<String>,
pub last_seen: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DirectMessage {
pub id: i64,
pub sender_uuid: String,
pub sender_name: String,
pub recipient_uuid: String,
pub content: String,
pub is_read: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GameInvite {
pub id: String,
pub sender_uuid: String,
pub sender_name: String,
pub recipient_uuid: String,
pub instance_id: String,
pub instance_name: String,
pub server_id: Option<i64>,
pub server_name: Option<String>,
pub status: String,
pub created_at: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserPost {
pub id: i64,
pub user_uuid: String,
pub author_name: String,
pub content: String,
pub image_url: Option<String>,
pub likes_count: i64,
#[serde(default)]
pub liked_by_me: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ProfileGuild {
pub id: String,
pub name: String,
pub tag: String,
pub role: String,
}
/// A rank reported by a server's permissions plugin (LuckPerms), shown read-only.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ProfileRank {
pub display: String,
pub prefix: String,
pub server_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ProfileActivity {
/// `join`, `death`, `advancement`, `kill` or `achievement`.
pub kind: String,
pub text: String,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MutualFriend {
pub uuid: String,
pub username: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserProfileView {
pub uuid: String,
pub username: String,
pub bio: String,
pub banner_url: Option<String>,
pub custom_badge: Option<String>,
pub title: Option<String>,
pub global_level: i64,
pub global_xp: i64,
pub server_levels: Vec<ServerLevelInfo>,
pub featured_achievement: Option<Achievement>,
pub achievements_count: usize,
pub posts: Vec<UserPost>,
pub level_info: UserLevelInfo,
pub achievements: Vec<Achievement>,
pub badges: Vec<String>,
pub friends_count: i64,
pub created_at: String,
pub stats: Option<serde_json::Value>,
#[serde(default)]
pub online: bool,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub guild: Option<ProfileGuild>,
#[serde(default)]
pub rank: Option<ProfileRank>,
/// The server they spend the most time on.
#[serde(default)]
pub favorite_server: Option<String>,
/// Total balance across the economies they hold money in.
#[serde(default)]
pub wealth: f64,
#[serde(default)]
pub recent_activity: Vec<ProfileActivity>,
#[serde(default)]
pub mutual_friends: Vec<MutualFriend>,
/// Relationship to the person looking: `self`, `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
#[serde(default)]
pub relationship: String,
/// Profile cosmetic: a `#rrggbb` accent colour.
#[serde(default)]
pub accent_color: Option<String>,
#[serde(default)]
pub discord_linked: bool,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+9 -6
View File
@@ -1,14 +1,15 @@
# SCOPENET admin panel
#
# cp .env.example .env # set ADMIN_PASSWORD at least
# docker compose up -d
# cp .env.example .env # set ADMIN_PASSWORD and PUBLIC_URL
# docker compose up -d --build
#
# Then open http://localhost:8080 (or your domain behind a reverse proxy).
services:
panel:
image: ghcr.io/scopeddlol/scopenet-mc-panel:latest
# Or build from source: docker compose build
build: .
# Tag the image built from this checkout so an older GHCR release is not used.
image: scopenet-panel:local
build:
context: .
container_name: scopenet-panel
restart: unless-stopped
ports:
@@ -23,6 +24,8 @@ services:
# Optional — can also be set in the panel's Settings page.
CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
PUBLIC_URL: ${PUBLIC_URL:-}
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
RUST_LOG: ${RUST_LOG:-info}
volumes:
- panel-data:/data
@@ -30,7 +33,7 @@ services:
deploy:
resources:
limits:
memory: 256M
memory: ${PANEL_MEMORY:-256M}
volumes:
panel-data:
+26 -5
View File
@@ -4,7 +4,7 @@
```bash
cp .env.example .env # set ADMIN_PASSWORD
docker compose up -d
docker compose up -d --build
docker compose logs -f panel # first start prints the admin account
```
@@ -19,7 +19,7 @@ Everything the panel stores lives in the `panel-data` volume (`/data`):
**Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume.
**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically.
**Updating this checkout:** `docker compose up -d --build`. Database migrations run automatically.
### HTTPS
@@ -52,20 +52,27 @@ On the **Server** tab: name, address, port.
- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added).
- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions).
Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin.
Official servers use `online-mode=true`, authlib-injector and the SCOPENET server integration. See [server setup](server-integration.md) for the startup flag, tokens and supported versions.
### Access
- **Everyone** — any launcher, including offline and Microsoft accounts.
- **Everyone** — any launcher, including local offline accounts.
- **Signed-in players** — any panel account.
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
## Players
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*.
- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent.
- Each account has a permanent UUID. New accounts receive a random UUID; existing accounts keep theirs. Username changes preserve inventory and reserve prior names. Players change username, skin and permitted capes from the launcher.
- Disabling an account signs it out everywhere on the next request.
- Ten failed logins lock an account for five minutes.
- **Username blacklist:** Settings → Username blacklist. Each entry matches a complete name; wrap it as `*word*` to block it inside longer names. The short starter list covers obvious offensive terms and can be edited. New registrations, admin-created accounts, Discord-created accounts, and username changes all use it.
### Discord and password reset email
Set **Settings → Auth server → Public address** to the HTTPS URL players use. In **Settings → Discord**, save the application client ID and client secret, and add `https://your-panel.example/api/v1/auth/discord/callback` to the Discord application's OAuth2 redirect URLs. Players can sign in with Discord in the launcher or connect an existing account under **Accounts → Connections → Discord**. With registration closed, Discord sign-in works for linked accounts only; open or approval mode can create new player accounts.
In **Settings → Resend SMTP**, save a Resend API key and a sender email from a verified domain. The panel sends password reset links through `smtp.resend.com` over TLS. A reset link expires after 30 minutes and works once. Use **Send a test email** and check the destination inbox and Resend activity log: SMTP acceptance confirms submission, while the inbox confirms delivery. Credentials are stored in the panel data volume and are not returned to the browser after saving; protect the volume and restrict access to the Admin Panel.
## Launcher design
@@ -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.
**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
1. Resolve the account (refreshing Microsoft tokens if needed).
1. Resolve the account and validate its private Yggdrasil session.
2. Fetch the instance from the panel (cached for offline play).
3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives.
4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked.
+76
View File
@@ -0,0 +1,76 @@
# BlueMap: the 3D map
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for the 3D world map. BlueMap runs on each Minecraft server as a plugin or mod and serves the map on its own web address. SCOPENET adds to it, and the launcher shows it inside the app exactly as a browser would.
## What you get
| On the map | Where it comes from |
|---|---|
| **Guild claims** as coloured regions with the guild's icon and name (one colour per guild, outlines follow the real shape, holes included) | the claim index |
| **Spawn, warps and guild homes** as pins | the server |
| **Homes** as pins (off by default, see privacy) | the server |
| **Markets and shops** as pins that list what is for sale | shop points + the panel's market |
| **Players** with BlueMap's own live markers; **click one** for a side panel | BlueMap + SCOPENET |
The player panel shows level, guild, rank, title, playtime and server level. Inside the launcher it also offers **Send teleport request**, **Send message** and **Invite to my guild** (you need to be signed in with a SCOPENET account, and the other player must be online on that server; inviting needs a leader or officer role). In a plain browser it shows the info and points you to the launcher for the actions.
## Set it up
1. **Install BlueMap** on the server: the Paper plugin, or the Fabric/Forge mod, from <https://bluemap.bluecolored.de/>. Start the server once. BlueMap asks you to accept a download of the Minecraft client files in its config (`core.conf`: `accept-download: true`), then renders the world.
2. **Make its web address reachable.** BlueMap serves on port 8100 by default. Put it behind HTTPS with a reverse proxy (recommended; see below) or open the port.
3. **Install the SCOPENET plugin or Fabric mod** (Paper, or Fabric 1.20.1). It finds BlueMap by itself and adds its markers and script. Nothing else to configure.
4. **Tell the panel the address:** *Servers → your server → Settings → Map address (BlueMap)*, for example `https://map.example.com`. Players then see a **Live Map** button for that server in the launcher.
`bluemap.enabled` (Paper `config.yml`, Fabric `scopenet.properties`) turns the addon off. `bluemap.show-homes` / `bluemap.show_homes` shows every player's homes.
## Shop points (physical shops)
A shop point is a block that opens the market or the server shop when right-clicked. It works with the normal windows (chest window on Paper; the client mod's shop and market windows where installed) and appears as a pin on the map.
```
/market point add <name> [market|shop] (look at a block; needs scopenet.command.market.point, default op)
/market point remove <name>
/market point list
```
`market` opens the player market and lists recent listings on the map pin; `shop` opens the server shop.
## Inside the launcher
The launcher loads your map address in a frame, so everything BlueMap does in a browser works there too. SCOPENET's script on the map page passes player clicks to the launcher, which shows the side panel. A few things make it work or not:
* **Use HTTPS.** Loading an `http://` map inside an HTTPS page can be blocked as mixed content on some systems.
* **Don't forbid framing.** If your reverse proxy sends `X-Frame-Options: DENY` or a `Content-Security-Policy: frame-ancestors` that excludes the launcher, the map won't load in the app. Remove that header for the map address. The launcher's **Browser** button opens the same map in your browser either way.
* The map address is checked by the panel (web address only, no credentials).
## Privacy
* The map is public to anyone who knows its address, like any BlueMap. **Homes are off by default** for that reason. Claims and guild names are visible by design.
* `players.json`, which drives the browser panel, holds only public facts (name, level, title, guild, rank, playtime). Never balances.
* Actions from the map go through the panel, are rate-limited, and need a signed-in account. A message or teleport request reaches only players who are online on that server.
## Reverse proxy example (nginx)
```nginx
server {
server_name map.example.com;
listen 443 ssl;
location / {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection ""; # BlueMap uses server-sent events for live updates
proxy_buffering off;
}
}
```
## How it works (for developers)
* `net.scopenet.core.map` builds the map data without any map software: `ChunkRegions` turns chunks into outlines (tested with an area-conservation check), `MapModel` builds pins and regions and escapes every name, `MapService` gathers it from any thread.
* `integrations/bluemap` (`MapAddon`) is the only code that touches BlueMap's API. It copies `scopenet.js`, `scopenet.css` and the icons into BlueMap's web root, registers them, and refreshes the marker sets every 10 seconds.
* Map actions are queued in the panel (`server_actions`); the server collects them every 3 seconds (`ActionPoller`) and carries them out with its normal rules. Guild invitations are real invitations (`guild_invites`) that the other player accepts in the launcher or with `/guild accept`.
## Not verified yet
The BlueMap addon, the Paper and Fabric wiring, and the launcher's map view have not been compiled or run against a real BlueMap and Minecraft: the build environment cannot download them. The geometry, map model, shop points, action poller and guild-invite commands are unit-tested, and so are the panel endpoints. Build it, and report what BlueMap says in its log and what the map shows.
+44
View File
@@ -0,0 +1,44 @@
# 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 2D world map and claim land in the launcher, install BlueMap on the
game server and configure its public web address in the panel. See
[Live map setup](live-map.md).
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
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.
+21
View File
@@ -0,0 +1,21 @@
# Live map and land claims
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for terrain. Install BlueMap on the Minecraft server and make its web app reachable from players' computers. In the panel's **Servers → server settings**, enter the BlueMap **Map address**. The launcher opens the same map in its Live Map window and reads BlueMap's low resolution 2D tile images for the Guilds territory view.
The terrain address must serve BlueMap's `settings.json`, per-map `settings.json`, and low resolution PNG tiles from the same URL path. A URL such as `https://maps.example.com/` works. If BlueMap is hosted below a path, include the trailing slash, for example `https://example.com/bluemap/`.
## Center on the player
Turn on **Live player positions** in the server settings and leave `livemap.enabled: true` in the SCOPENET plugin or mod configuration. The game server sends current positions to the panel. The panel returns each signed-in player only their own position to the launcher. The **Find me** button centers the territory view on it; the blue dot marks their position. Positions expire when the server stops sending updates.
The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable.
## Claim controls
In **Guilds & Territories**, select the game server and dimension. Left click a chunk to claim it for your guild, or drag across chunks to claim an area. Right click or right drag unclaims your guild's chunks. Alt drag pans; the wheel zooms. The overlay shows your guild's claims in green and other guilds' claims in red. The panel checks membership and claim limits for every request; only successful claims appear in the final map state.
BlueMap must have rendered the area to show terrain. Claims can still be managed by the in-game `/claim` and `/unclaim` commands when the web map is unavailable.
## Existing world mirror
Older SCOPENET game integrations uploaded region chunks to the panel under `/api/server/v1/livemap`. The panel retains those ingest endpoints for compatibility with existing servers, but the launcher terrain comes from BlueMap. Current integrations send player positions only.
-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).
+21
View File
@@ -0,0 +1,21 @@
# Ranks, guild roles, chat and warps
## Level ranks
In the panel's **Leveling** page, create a **Player Title / Prefix** reward at each global level milestone. A title can name a LuckPerms group and a Discord role ID. The highest title the player has earned is shown in Paper chat and, when LuckPerms is installed, its configured group is assigned automatically. SCOPENET only changes LuckPerms groups explicitly named on title rewards. Existing panel group to LuckPerms mappings still work independently.
In **Settings → Discord community**, enable role sync and optionally set a base role ID. Linked players receive the Discord role for their highest earned title; players without a mapped title receive the base role. When a higher title replaces a lower one, the old managed title role is removed. Players without a linked Discord account cannot receive a Discord role; in-game chat shows the base `Novice` title until they earn a title.
Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname: message`. LuckPerms prefixes and suffixes decorate the name. Messages support `**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`, backtick code, and Bukkit `&` formatting. `&#RRGGBB` colors and `&` codes require `scopenet.chat.color`, which is granted to operators by default. Players set or clear a persistent nickname with `/nick <name|off>`; chat also respects display names set by other plugins.
## Guilds
Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`.
The in-app territory view uses the configured BlueMap 2D tile address for each server. It centers on the player's last position when position relay is enabled. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
## Warps and item sharing on Paper
Players use `/reqwarp <name>` at the requested location. An operator or player with `scopenet.command.warp.manage` uses `/warprequests`, then `/warpapprove <name>` or `/warpdeny <name>`. Admins can create and remove warps directly with `/setwarp <name>` and `/delwarp <name>`. Pending requests are stored in `plugins/SCOPENET/warp_requests.yml`; approved warps are stored in `warps.yml`.
`/hand` sends the held item as a clickable chat entry. Other players can inspect a read-only snapshot for five minutes. The launcher **Command Guide** lists these commands and their permission nodes.
+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.
+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,61 @@
# 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
# --- BlueMap (1.20.1) --------------------------------------------------------
# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a
# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address).
bluemap.enabled=true
# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link.
bluemap.show_homes=false
+143
View File
@@ -0,0 +1,143 @@
# ==============================================================================
# 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 live player positions to the panel so the launcher can center its
# BlueMap territory view. Configure BlueMap's public address in the panel.
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
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# ==============================================================================
bluemap:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# ==============================================================================
# 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,46 @@
package net.scopenet.api;
import java.util.UUID;
/**
* A snapshot of one SCOPENET account. It is a copy: fetch a new one (or use the
* cached copy, which is refreshed every few seconds for online players) to see changes.
*
* @param rankTitle the title earned from global level rewards, or {@code null}
* @param leaderboard the position on the global leaderboard, or 0 when unranked
* @param rankGroup the player's LuckPerms primary group as reported by the server, or {@code null}
* @param guild the player's guild on this server's instance, or {@code null}
*/
public record ScopenetPlayer(
UUID uuid,
String name,
int globalLevel,
long globalXp,
long globalLevelXp,
long globalNextLevelXp,
double globalProgress,
String rankTitle,
int leaderboard,
int serverLevel,
long serverXp,
String serverRank,
GuildSummary guild,
double balance,
long playtimeSeconds,
long serverPlaytimeSeconds,
int kills,
int deaths,
int friends,
int achievements,
QuestProgress dailyQuests,
QuestProgress weeklyQuests,
String joined,
String rankGroup,
String rankDisplay,
String rankPrefix,
String rankSuffix) {
public boolean inGuild() { return guild != null; }
public double killDeathRatio() { return deaths == 0 ? kills : (double) kills / deaths; }
}
@@ -0,0 +1,6 @@
package net.scopenet.api;
/** What a player's XP and level are after {@link ScopenetApi#addXP}. */
public record XpResult(XpScope scope, long xp, int level, int previousLevel) {
public boolean leveledUp() { return level > previousLevel; }
}
@@ -0,0 +1,9 @@
package net.scopenet.api;
/** Which level track an XP change applies to. */
public enum XpScope {
/** The account-wide SCOPENET level, shared by every server. */
GLOBAL,
/** The level on the server the plugin is running on. */
SERVER
}
@@ -0,0 +1,27 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player unlocked a SCOPENET achievement. */
public final class AchievementUnlockEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String achievementId;
private final String title;
private final long xp;
public AchievementUnlockEvent(UUID playerId, String achievementId, String title, long xp) {
super(playerId);
this.achievementId = achievementId;
this.title = title;
this.xp = xp;
}
public String getAchievementId() { return achievementId; }
public String getTitle() { return title; }
/** XP the achievement was worth. */
public long getXp() { return xp; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,30 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player founded or joined a guild. */
public final class GuildJoinEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String guildId;
private final String guildName;
private final String guildTag;
private final String role;
public GuildJoinEvent(UUID playerId, String guildId, String guildName, String guildTag, String role) {
super(playerId);
this.guildId = guildId;
this.guildName = guildName;
this.guildTag = guildTag;
this.role = role;
}
public String getGuildId() { return guildId; }
public String getGuildName() { return guildName; }
public String getGuildTag() { return guildTag; }
/** {@code leader}, {@code officer} or {@code member}. */
public String getRole() { return role; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,27 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player left (or was removed from) a guild. */
public final class GuildLeaveEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String guildId;
private final String guildName;
private final String guildTag;
public GuildLeaveEvent(UUID playerId, String guildId, String guildName, String guildTag) {
super(playerId);
this.guildId = guildId;
this.guildName = guildName;
this.guildTag = guildTag;
}
public String getGuildId() { return guildId; }
/** May be {@code null} if the guild no longer exists. */
public String getGuildName() { return guildName; }
public String getGuildTag() { return guildTag; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,18 @@
package net.scopenet.api.event;
import org.bukkit.event.Event;
import java.util.UUID;
/**
* Base class for SCOPENET events. They are fired on the server thread when the
* panel reports a change for a player who is on this server.
*/
public abstract class ScopenetEvent extends Event {
private final UUID playerId;
protected ScopenetEvent(UUID playerId) {
this.playerId = playerId;
}
public UUID getPlayerId() { return playerId; }
}
@@ -0,0 +1,30 @@
package net.scopenet.api.event;
import net.scopenet.api.XpScope;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player's global or server level went up. */
public final class ScopenetLevelUpEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final XpScope scope;
private final int level;
private final int previousLevel;
private final long xp;
public ScopenetLevelUpEvent(UUID playerId, XpScope scope, int level, int previousLevel, long xp) {
super(playerId);
this.scope = scope;
this.level = level;
this.previousLevel = previousLevel;
this.xp = xp;
}
public XpScope getScope() { return scope; }
public int getLevel() { return level; }
public int getPreviousLevel() { return previousLevel; }
public long getXp() { return xp; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
+16
View File
@@ -0,0 +1,16 @@
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
@@ -0,0 +1,211 @@
package net.scopenet.bluemap;
import com.flowpowered.math.vector.Vector2d;
import com.flowpowered.math.vector.Vector3d;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.BlueMapAPI;
import de.bluecolored.bluemap.api.BlueMapMap;
import de.bluecolored.bluemap.api.markers.Marker;
import de.bluecolored.bluemap.api.markers.MarkerSet;
import de.bluecolored.bluemap.api.markers.POIMarker;
import de.bluecolored.bluemap.api.markers.ShapeMarker;
import de.bluecolored.bluemap.api.math.Color;
import de.bluecolored.bluemap.api.math.Shape;
import net.scopenet.core.map.ChunkRegions;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapModel;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
/**
* Puts SCOPENET on the BlueMap 3D map:
* <ul>
* <li>guild land as coloured regions, with the guild's icon and name,</li>
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
* <li>markets and shops as pins that list what is for sale,</li>
* <li>a script that opens a side panel when you click a player.</li>
* </ul>
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
* nothing if BlueMap isn't installed.
*/
public final class MapAddon {
/** Where the addon gets its data. Both calls must be safe from any thread. */
public interface Feed {
MapData snapshot();
JsonObject playersJson();
}
static final String WEB_DIR = "assets/scopenet";
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
static final float CLAIM_Y = 66f;
private final Feed feed;
private final Logger log;
private ScheduledExecutorService worker;
private volatile boolean enabled;
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
/** Hook into BlueMap. Returns a handle to stop it. */
public static MapAddon start(Feed feed, Logger log) {
MapAddon addon = new MapAddon(feed, log);
BlueMapAPI.onEnable(addon::enable);
BlueMapAPI.onDisable(api -> addon.disable());
return addon;
}
public void stop() { disable(); }
// ---- lifecycle -------------------------------------------------------------------------
private synchronized void enable(BlueMapAPI api) {
try {
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
Files.createDirectories(root);
for (String name : ASSETS) copy(name, root.resolve(name));
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
} catch (IOException | RuntimeException e) {
log.warning("SCOPENET could not add its files to BlueMap: " + e);
return;
}
enabled = true;
worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-bluemap");
t.setDaemon(true);
return t;
});
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
}
private synchronized void disable() {
enabled = false;
if (worker != null) worker.shutdownNow();
worker = null;
}
private static void copy(String name, Path target) throws IOException {
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
if (in == null) throw new IOException("missing resource " + name);
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// ---- refresh ---------------------------------------------------------------------------
private void refresh(BlueMapAPI api) {
if (!enabled) return;
try {
MapData data = feed.snapshot();
writePlayers(api);
for (BlueMapMap map : api.getMaps()) update(map, data);
} catch (RuntimeException | IOException e) {
log.fine("SCOPENET map refresh failed: " + e);
}
}
private void writePlayers(BlueMapAPI api) throws IOException {
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
Path tmp = file.resolveSibling("players.json.tmp");
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
private void update(BlueMapMap map, MapData data) {
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
}
/** Replace a marker set's contents in place, so the map doesn't flicker. */
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
wanted.forEach(set::put);
}
// ---- markers ---------------------------------------------------------------------------
private static Shape shape(ChunkRegions.Ring ring) {
List<Vector2d> points = new ArrayList<>();
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
return new Shape(points);
}
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
ShapeMarker marker = ShapeMarker.builder()
.label(c.name())
.shape(shape(c.outer()), CLAIM_Y)
.holes(holes)
.lineColor(new Color(c.color(), 0.95f))
.fillColor(new Color(c.color(), 0.28f))
.lineWidth(3)
.depthTestEnabled(false)
.detail(c.detail())
.build();
out.put("claim-" + c.id(), marker);
}
return out;
}
/** One icon and name per area, in the middle of it, fading out when you are far away. */
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
POIMarker.Builder builder = POIMarker.builder()
.label(c.name() + " [" + c.tag() + "]")
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
.styleClasses("scopenet-guild-icon")
.maxDistance(1600)
.detail(c.detail());
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
out.put("guild-" + c.id(), builder.build());
}
return out;
}
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.Pin p : data.pins()) {
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
if (!p.dimension().equals(dimension) || isShop != shops) continue;
String icon = switch (p.kind()) {
case "spawn" -> "spawn.svg";
case "warp" -> "warp.svg";
case "home" -> "home.svg";
case "guild_home" -> "guild-home.svg";
case "market" -> "market.svg";
default -> "shop.svg";
};
out.put(p.id(), POIMarker.builder()
.label(p.label())
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
.icon(WEB_DIR + "/" + icon, 16, 16)
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
.maxDistance(2400)
.detail(p.detail())
.build());
}
return out;
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 243 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 3l11 4v8c0 7-4.6 12-11 14C9.6 27 5 22 5 15V7z" fill="#8b6cff" stroke="#fff" stroke-width="2"/><path d="M16 9l2.2 4.6 5 .7-3.6 3.5.9 5L16 20.4 11.5 22.8l.9-5-3.6-3.5 5-.7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 288 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fbbf24" stroke="#ffffff" stroke-width="2"/><path d="M16 7l9 8h-3v8h-4v-5h-4v5h-4v-8H7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fb7185" stroke="#ffffff" stroke-width="2"/><path d="M7 12l2-5h14l2 5v2c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3zM9 19h14v6H9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,64 @@
/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */
/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */
.scopenet-guild-icon img,
.bm-marker-poi.scopenet-guild-icon img {
width: 34px !important;
height: 34px !important;
object-fit: cover;
border-radius: 9px;
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
background: #1a1d25;
}
.scopenet-pin img,
.bm-marker-poi.scopenet-pin img {
width: 26px !important;
height: 26px !important;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
}
/* Popup text inside marker details. */
.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; }
.scopenet-detail span { opacity: 0.7; }
.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; }
.scopenet-list { margin: 4px 0 0; padding-left: 16px; }
/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */
#scopenet-panel {
position: fixed;
top: 64px;
right: 14px;
width: 300px;
max-height: calc(100vh - 90px);
overflow-y: auto;
z-index: 50;
padding: 16px;
border-radius: 14px;
background: rgba(20, 22, 30, 0.96);
color: #e6e8ee;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 13px;
animation: scopenet-in 0.18s ease;
}
@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } }
#scopenet-panel button.close {
position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px;
background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer;
}
#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; }
#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; }
#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; }
#scopenet-panel .title { color: #fbbf24; font-size: 12px; }
#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; }
#scopenet-panel .chip.lv { color: #8b6cff; }
#scopenet-panel .chip.guild { color: #22d3ee; }
#scopenet-panel .chip.rank { color: #fbbf24; }
#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; }
#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; }
#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; }
@@ -0,0 +1,115 @@
/*
* SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and
* inside the SCOPENET launcher.
*
* - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the
* server keeps up to date next to this file.
* - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel
* with the actions that need a signed-in account: teleport request, message, guild invite.
*/
(function () {
'use strict';
var BASE = 'assets/scopenet/';
var PLAYER_PREFIX = 'bm-marker-bm-player-';
var embedded = window.parent && window.parent !== window;
var players = {};
var selected = null;
function esc(text) {
return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-<uuid>". */
function uuidFromId(id) {
return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null;
}
/** Which player marker is under this screen point? Works even where the marker ignores pointer events. */
function playerAt(x, y) {
var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]');
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id);
}
return null;
}
function hours(seconds) {
seconds = Number(seconds) || 0;
return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm';
}
function panel() {
var el = document.getElementById('scopenet-panel');
if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); }
return el;
}
function render() {
var el = document.getElementById('scopenet-panel');
if (!selected) { if (el) el.remove(); return; }
var p = players[selected] || players[selected.toLowerCase()] || {};
el = panel();
var name = p.name || 'Player';
el.innerHTML =
'<button class="close" aria-label="Close">×</button>' +
'<div class="head"><img alt="" src="assets/playerheads/' + esc(selected) + '.png" onerror="this.src=\'assets/steve.png\'">' +
'<div><h3>' + esc(name) + '</h3>' + (p.title ? '<div class="title">' + esc(p.title) + '</div>' : '') + '</div></div>' +
'<div class="chips">' +
'<span class="chip lv">Level ' + esc(p.level || 1) + '</span>' +
(p.guild ? '<span class="chip guild">[' + esc(p.guildTag) + '] ' + esc(p.guild) + '</span>' : '') +
(p.rank ? '<span class="chip rank">' + esc(p.rank) + '</span>' : '') + '</div>' +
'<div class="facts">' +
'<div>Playtime<strong>' + esc(hours(p.playtime)) + '</strong></div>' +
'<div>Server level<strong>' + esc(p.serverLevel || 1) + '</strong></div>' +
(p.guildRole ? '<div>Guild role<strong>' + esc(p.guildRole) + '</strong></div>' : '') + '</div>' +
'<p class="note">Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.</p>';
el.querySelector('.close').addEventListener('click', function () { select(null); });
}
function select(uuid) {
selected = uuid;
if (embedded) {
window.parent.postMessage(uuid
? { source: 'scopenet-map', type: 'player', uuid: uuid, name: (players[uuid] || {}).name || '' }
: { source: 'scopenet-map', type: 'deselect' }, '*');
} else {
render();
}
}
function refresh() {
fetch(BASE + 'players.json?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j && j.players) { players = j.players; if (!embedded && selected) render(); } })
.catch(function () { /* the file appears once the server has written it */ });
}
// A click is a press and release in nearly the same place; a drag moves the map.
var down = null;
document.addEventListener('pointerdown', function (e) { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true);
document.addEventListener('pointerup', function (e) {
var start = down;
down = null;
if (!start || e.button > 0) return;
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6 || Date.now() - start.t > 700) return;
if (e.target && e.target.closest && e.target.closest('#scopenet-panel')) return;
var uuid = playerAt(e.clientX, e.clientY);
if (uuid) select(uuid);
else if (selected) select(null);
}, true);
window.addEventListener('message', function (e) {
if (e.source === window.parent && e.data && e.data.source === 'scopenet-launcher' && e.data.type === 'hello') { /* acknowledged */ }
});
refresh();
setInterval(refresh, 10000);
if (embedded) window.parent.postMessage({ source: 'scopenet-map', type: 'ready' }, '*');
// Exposed for tests.
window.__scopenet = { uuidFromId: uuidFromId, esc: esc, hours: hours };
})();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#f97316" stroke="#ffffff" stroke-width="2"/><path d="M8 10h3l2 10h10l2-7H12" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/><circle cx="14" cy="24" r="1.8" fill="#fff"/><circle cx="22" cy="24" r="1.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#34d399" stroke="#ffffff" stroke-width="2"/><path d="M16 7l2.6 6.4 6.9.5-5.3 4.5 1.7 6.7L16 21.3 10.1 25l1.7-6.7-5.3-4.5 6.9-.5z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 270 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#8b6cff" stroke="#ffffff" stroke-width="2"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+12
View File
@@ -0,0 +1,12 @@
allprojects {
group = 'net.scopenet'
version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
repositories { mavenCentral() }
}
subprojects {
apply plugin: 'java'
java.sourceCompatibility = JavaVersion.VERSION_17
java.targetCompatibility = JavaVersion.VERSION_17
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
tasks.withType(Test).configureEach { useJUnitPlatform() }
}
+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,68 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Optional;
import java.util.UUID;
/**
* Collects what the panel queued for this server (a teleport request or message sent from the map, an invitation
* notice) every few seconds, and carries it out with the server's normal rules.
*/
public final class ActionPoller {
/** Teleport requests use each platform's own TPA, so they obey its rules and permissions. */
public interface Tpa { void request(CorePlayer requester, CorePlayer target); }
public static final long EVERY_MS = 3_000;
private final Env env;
private final Tpa tpa;
private long next;
private volatile boolean running;
public ActionPoller(Env env, Tpa tpa) { this.env = env; this.tpa = tpa; }
/** Call often (every tick or second) on the server thread; it asks the panel at most every few seconds. */
public void tick() {
long now = env.clock.getAsLong();
if (running || now < next) return;
next = now + EVERY_MS;
running = true;
env.platform.runAsync(() -> {
try {
JsonElement answer = env.panel.call("actions/poll", new JsonObject());
JsonArray actions = answer.isJsonObject() && answer.getAsJsonObject().has("actions") ? answer.getAsJsonObject().getAsJsonArray("actions") : new JsonArray();
if (actions.size() > 0) env.platform.runMain(() -> actions.forEach(a -> apply(a.getAsJsonObject())));
} catch (Exception e) {
// The panel is briefly unreachable: try again on the next round.
} finally {
running = false;
}
});
}
private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; }
private Optional<CorePlayer> player(String uuid) {
try { return env.platform.player(UUID.fromString(uuid)); } catch (IllegalArgumentException e) { return Optional.empty(); }
}
void apply(JsonObject a) {
String kind = text(a, "kind");
Optional<CorePlayer> to = player(text(a, "to_uuid"));
if (to.isEmpty()) return; // left the server since it was queued
switch (kind) {
case "notify" -> to.get().send(text(a, "text"));
case "message" -> to.get().send(Format.GRAY + "[" + Format.AQUA + "Map" + Format.GRAY + "] " + Format.YELLOW + text(a, "from_name") + Format.GRAY + ": " + Format.WHITE + text(a, "text"));
case "tpa" -> {
Optional<CorePlayer> from = player(text(a, "from_uuid"));
if (from.isEmpty()) { to.get().send(Format.GRAY + text(a, "from_name") + " asked to teleport to you, but has left."); return; }
if (!from.get().hasPermission("scopenet.command.tpa")) { from.get().send(Format.RED + "You do not have permission to send teleport requests."); return; }
tpa.request(from.get(), to.get());
}
default -> { }
}
}
}
@@ -0,0 +1,252 @@
package net.scopenet.core;
import com.google.gson.*;
import net.scopenet.integration.ChunkCheckResult;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
/**
* Talks to the optional SCOPENET client mod over the {@link Wire} channels. The client says hello; from then on this
* pushes that player's state (level, balance, guild, quests), the claims around them, and answers market and shop
* requests. Display only: every action the client takes is an ordinary command, checked like any other.
*/
public final class ClientLink {
private static final int RADIUS = 4;
private final Env env;
private final PlayerCache cache;
private final BiConsumer<CorePlayer, byte[]> out;
private final String serverName;
private final Map<UUID, String> lastState = new ConcurrentHashMap<>();
private final Map<UUID, String> lastClaimsKey = new ConcurrentHashMap<>();
private final Set<UUID> clients = ConcurrentHashMap.newKeySet();
private final Map<UUID, Long> lastRequest = new ConcurrentHashMap<>();
/** One request per player at most this often; a modded client can't turn the link into a panel flood. */
static final long MIN_REQUEST_GAP_MS = 400;
public ClientLink(Env env, PlayerCache cache, String serverName, BiConsumer<CorePlayer, byte[]> out) {
this.env = env; this.cache = cache; this.serverName = serverName; this.out = out;
}
public boolean hasClient(UUID uuid) { return clients.contains(uuid); }
public void disconnected(UUID uuid) {
clients.remove(uuid); lastState.remove(uuid); lastClaimsKey.remove(uuid); lastRequest.remove(uuid); cache.forget(uuid);
}
private void send(CorePlayer p, JsonObject message) { out.accept(p, Wire.encode(message.toString())); }
/** A packet from a player's client. Unknown or malformed messages are ignored. */
public void receive(CorePlayer p, byte[] payload) {
JsonObject msg;
try { msg = JsonParser.parseString(Wire.decode(payload)).getAsJsonObject(); }
catch (RuntimeException e) { return; }
String type = PlayerCache.str(msg, "t", "");
if (!env.features.clientLink()) return;
long now = env.clock.getAsLong();
Long previous = lastRequest.put(p.uuid(), now);
if (previous != null && now - previous < MIN_REQUEST_GAP_MS && !type.equals("hello")) return;
switch (type) {
case "hello" -> hello(p);
case "refresh" -> { if (clients.contains(p.uuid())) { lastState.remove(p.uuid()); cache.refresh(p, info -> pushState(p)); } }
case "claims" -> { if (clients.contains(p.uuid())) { lastClaimsKey.remove(p.uuid()); pushClaims(p); } }
case "market" -> { if (clients.contains(p.uuid())) market(p); }
case "shop" -> { if (clients.contains(p.uuid())) shop(p); }
default -> { }
}
}
private void hello(CorePlayer p) {
clients.add(p.uuid());
Features f = env.features;
JsonObject m = new JsonObject();
m.addProperty("t", "hello");
m.addProperty("v", Wire.VERSION);
m.addProperty("server", serverName);
m.addProperty("currency", f.currencySymbol());
JsonObject features = new JsonObject();
features.addProperty("essentials", f.essentials());
features.addProperty("economy", f.economy());
features.addProperty("guilds", f.guilds());
features.addProperty("claims", f.guilds() && f.landClaiming());
features.addProperty("social", f.social());
m.add("features", features);
send(p, m);
cache.refresh(p, info -> pushState(p));
if (cache.info(p.uuid()) != null) pushState(p);
pushClaims(p);
}
/** Push the state if it changed since the last push. */
public void pushState(CorePlayer p) {
if (!clients.contains(p.uuid())) return;
JsonObject i = cache.info(p.uuid());
if (i == null) return;
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild");
JsonObject quests = PlayerCache.obj(i, "quests");
JsonObject m = new JsonObject();
m.addProperty("t", "state");
m.addProperty("name", p.name());
m.addProperty("level", (long) PlayerCache.num(global, "level", 1));
m.addProperty("xp", (long) PlayerCache.num(global, "xp", 0));
m.addProperty("xpNow", (long) PlayerCache.num(global, "current_level_xp", 0));
m.addProperty("xpNext", (long) PlayerCache.num(global, "next_level_xp", 0));
m.addProperty("xpPct", PlayerCache.num(global, "progress_pct", 0));
m.addProperty("title", PlayerCache.str(global, "title", ""));
m.addProperty("serverLevel", (long) PlayerCache.num(server, "level", 1));
m.addProperty("serverXpPct", PlayerCache.num(server, "progress_pct", 0));
if (guild != null) {
JsonObject g = new JsonObject();
g.addProperty("name", PlayerCache.str(guild, "name", ""));
g.addProperty("tag", PlayerCache.str(guild, "tag", ""));
g.addProperty("role", PlayerCache.str(guild, "role", ""));
g.addProperty("claims", (long) PlayerCache.num(guild, "claims", 0));
m.add("guild", g);
}
m.addProperty("balance", PlayerCache.num(i, "balance", 0));
m.addProperty("playtime", (long) PlayerCache.num(i, "playtime_secs", 0));
for (String period : List.of("daily", "weekly")) {
JsonObject q = PlayerCache.obj(quests, period);
JsonObject o = new JsonObject();
o.addProperty("done", (long) PlayerCache.num(q, "completed", 0));
o.addProperty("total", (long) PlayerCache.num(q, "total", 0));
m.add(period, o);
}
JsonObject rank = PlayerCache.obj(i, "rank");
if (rank != null) m.addProperty("rank", PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")));
m.addProperty("kills", (long) PlayerCache.num(i, "kills", 0));
m.addProperty("deaths", (long) PlayerCache.num(i, "deaths", 0));
String text = m.toString();
if (text.equals(lastState.put(p.uuid(), text))) return;
send(p, m);
}
/** Claims around the player; sent when they change chunk or the claim map changes. */
public void pushClaims(CorePlayer p) {
if (!clients.contains(p.uuid()) || !env.features.guilds() || !env.features.landClaiming()) return;
Pos pos = p.pos();
int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4;
StringBuilder cells = new StringBuilder();
for (int dz = -RADIUS; dz <= RADIUS; dz++) {
for (int dx = -RADIUS; dx <= RADIUS; dx++) {
ChunkCheckResult r = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid());
cells.append(!r.claimed() ? '0' : r.allowed() ? '1' : '2');
}
}
ChunkCheckResult here = env.panel.check(pos.world(), cx, cz, p.uuid());
String key = pos.world() + ":" + cx + ":" + cz + ":" + cells;
if (key.equals(lastClaimsKey.put(p.uuid(), key))) return;
JsonObject m = new JsonObject();
m.addProperty("t", "claims");
m.addProperty("dim", pos.world());
m.addProperty("cx", cx);
m.addProperty("cz", cz);
m.addProperty("r", RADIUS);
m.addProperty("cells", cells.toString());
if (here.claimed()) {
JsonObject owner = new JsonObject();
owner.addProperty("name", here.guildName() == null ? "" : here.guildName());
owner.addProperty("tag", here.guildTag() == null ? "" : here.guildTag());
owner.addProperty("mine", here.allowed());
m.add("owner", owner);
}
send(p, m);
}
private void market(CorePlayer p) {
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
JsonObject m = new JsonObject();
m.addProperty("t", "market");
m.add("listings", el.isJsonArray() ? el.getAsJsonArray() : new JsonArray());
m.addProperty("balance", PlayerCache.num(cache.info(p.uuid()), "balance", 0));
send(p, m);
}, e -> notify(p, "error", "Market", "Could not load listings: " + e));
}
private void shop(CorePlayer p) {
JsonObject m = new JsonObject();
m.addProperty("t", "shop");
JsonArray items = new JsonArray();
ItemValues.all().forEach((id, price) -> {
JsonObject o = new JsonObject();
o.addProperty("id", id);
o.addProperty("name", id.toLowerCase(Locale.ROOT).replace('_', ' '));
o.addProperty("price", price);
items.add(o);
});
m.add("items", items);
p.heldItem().ifPresent(h -> {
JsonObject held = new JsonObject();
held.addProperty("id", h.id());
held.addProperty("name", h.name());
held.addProperty("count", h.count());
Double unit = ItemValues.unitPrice(h.id());
if (unit != null) held.addProperty("unit", unit);
m.add("held", held);
});
send(p, m);
}
/** Ask the client mod to open one of its windows ("market" or "shop"). Returns false if the player has no client mod. */
public boolean open(CorePlayer p, String screen) {
if (!clients.contains(p.uuid())) return false;
JsonObject m = new JsonObject();
m.addProperty("t", "open");
m.addProperty("screen", screen);
send(p, m);
return true;
}
/** A toast for the client (level-up, achievement, friend request…). */
public void notify(CorePlayer p, String kind, String title, String text) {
if (!clients.contains(p.uuid())) return;
JsonObject m = new JsonObject();
m.addProperty("t", "notify");
m.addProperty("kind", kind);
m.addProperty("title", title);
m.addProperty("text", text);
send(p, m);
}
/**
* Events the panel queued for this server (level-ups, achievements, guild changes). Safe to call from any thread:
* each becomes a toast on the player's client and forces a fresh state.
*/
public void onPanelEvents(JsonArray events) {
for (JsonElement element : events) {
if (!element.isJsonObject()) continue;
JsonObject n = element.getAsJsonObject();
String kind = PlayerCache.str(n, "kind", "");
String uuidText = PlayerCache.str(n, "uuid", "");
JsonObject d = PlayerCache.obj(n, "data");
UUID uuid;
try { uuid = UUID.fromString(uuidText); } catch (IllegalArgumentException e) { continue; }
String title, text;
switch (kind) {
case "level_up" -> { title = "Level up!"; text = ("server".equals(PlayerCache.str(d, "scope", "")) ? "Server level " : "Level ") + (long) PlayerCache.num(d, "level", 0); }
case "achievement" -> { title = "Achievement unlocked"; text = PlayerCache.str(d, "title", ""); }
case "guild_join" -> { title = "Guild"; text = "Joined " + PlayerCache.str(d, "guild", "a guild"); }
case "guild_leave" -> { title = "Guild"; text = "Left " + PlayerCache.str(d, "guild", "your guild"); }
default -> { continue; }
}
final String t = title, x = text, k = kind;
env.platform.runMain(() -> env.platform.player(uuid).ifPresent(p -> {
notify(p, k, t, x);
lastState.remove(uuid);
cache.refresh(p, info -> pushState(p));
}));
}
}
/** About once a second: new chunk, new claims, or new state for every client. */
public void tick() {
for (UUID id : new ArrayList<>(clients)) {
Optional<CorePlayer> p = env.platform.player(id);
if (p.isEmpty()) { disconnected(id); continue; }
pushClaims(p.get());
pushState(p.get());
}
}
}
@@ -0,0 +1,31 @@
package net.scopenet.core;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.BiConsumer;
/** A {@link CoreCommand} built from lambdas. */
final class Cmd implements CoreCommand {
private final String name;
private final List<String> aliases;
private final String permission;
private final BiConsumer<CorePlayer, String[]> run;
private final BiFunction<CorePlayer, String[], List<String>> complete;
Cmd(String name, List<String> aliases, String permission, BiConsumer<CorePlayer, String[]> run,
BiFunction<CorePlayer, String[], List<String>> complete) {
this.name = name; this.aliases = aliases; this.permission = permission; this.run = run; this.complete = complete;
}
static Cmd of(String name, List<String> aliases, BiConsumer<CorePlayer, String[]> run) {
return new Cmd(name, aliases, "scopenet.command." + name, run, (p, a) -> List.of());
}
Cmd completing(BiFunction<CorePlayer, String[], List<String>> c) { return new Cmd(name, aliases, permission, run, c); }
@Override public String name() { return name; }
@Override public List<String> aliases() { return aliases; }
@Override public String permission() { return permission; }
@Override public void run(CorePlayer player, String[] args) { run.accept(player, args); }
@Override public List<String> complete(CorePlayer player, String[] args) { return complete.apply(player, args); }
}
@@ -0,0 +1,70 @@
package net.scopenet.core;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Builds every shared command, wired to its services, and gates them on feature switches and permission nodes. */
public final class CommandSet {
public final EssentialsService essentials;
public final Jobs jobs;
public final ShopPoints shops;
private final GuildCommands guildCommands;
private final EconomyCommands economyCommands;
private final List<CoreCommand> commands = new ArrayList<>();
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir;
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
shops = new ShopPoints(dir.resolve("shops.json"));
EconomyCommands economy = new EconomyCommands(env, jobs, shops);
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
economyCommands = economy;
guildCommands = guilds;
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
}
private void add(Env env, List<CoreCommand> list, String system, java.util.function.BooleanSupplier enabled) {
for (CoreCommand c : list) commands.add(new Gated(c, system, enabled));
}
public List<CoreCommand> all() { return List.copyOf(commands); }
/** Run a command by name as this player, with the same checks as typing it. */
public void run(String name, CorePlayer player, String... args) {
for (CoreCommand c : commands) if (c.name().equals(name)) { c.run(player, args); return; }
throw new IllegalArgumentException("no command " + name);
}
/** What this command set knows that the map can show. */
public net.scopenet.core.map.MapSource mapSource() {
return new net.scopenet.core.map.MapSource() {
@Override public java.util.Map<String, Pos> warps() { return essentials.allWarps(); }
@Override public java.util.Map<java.util.UUID, java.util.Map<String, Pos>> homes() { return essentials.allHomes(); }
@Override public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
@Override public java.util.Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
}
/** Guild homes by guild id (for the map). */
public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
/** Call about once a second from the server thread. */
public void tick() { jobs.tick(); }
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
@Override public String name() { return inner.name(); }
@Override public List<String> aliases() { return inner.aliases(); }
@Override public String permission() { return inner.permission(); }
@Override public void run(CorePlayer p, String[] args) {
if (!enabled.getAsBoolean()) { p.send(Format.RED + system + " are disabled."); return; }
if (!p.hasPermission(inner.permission())) { p.send(Format.RED + "You do not have permission to use /" + inner.name() + "."); return; }
inner.run(p, args);
}
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
}
}
@@ -0,0 +1,14 @@
package net.scopenet.core;
import java.util.List;
/** One slash command, the same on every platform. */
public interface CoreCommand {
String name();
default List<String> aliases() { return List.of(); }
/** Node checked before {@link #run}; platforms apply LuckPerms or their default. */
String permission();
void run(CorePlayer player, String[] args);
/** Tab-completion candidates for the last argument. */
default List<String> complete(CorePlayer player, String[] args) { return List.of(); }
}
@@ -0,0 +1,24 @@
package net.scopenet.core;
import java.util.Optional;
import java.util.UUID;
/** What the shared logic needs from an online player. Each platform provides an adapter. */
public interface CorePlayer {
UUID uuid();
String name();
Pos pos();
/** Send a chat message. Legacy section-sign colour codes are allowed. Safe to call from the server thread. */
void send(String message);
void teleport(Pos destination);
/** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */
boolean hasPermission(String node);
/** Total time played, in seconds. */
long playtimeSeconds();
/** The stack in the main hand, empty if none. */
Optional<Item> heldItem();
/** Remove whatever is in the main hand. */
void clearHeld();
/** Put the stack in the inventory, or drop it at the player's feet if full. */
void give(Item item);
}
@@ -0,0 +1,199 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.*;
/**
* /balance /pay /baltop /shop /sell /market /orders /transactions, in chat. Money lives in the panel; selling and buying
* go through {@link Jobs} so a crash or outage never loses an item or a payment. Shop, sell-chest and market GUIs
* are the client mod's job; the commands here are what it (and plain chat) use.
*/
final class EconomyCommands {
private final Env env;
private final Jobs jobs;
private final ShopPoints shops;
EconomyCommands(Env env, Jobs jobs, ShopPoints shops) { this.env = env; this.jobs = jobs; this.shops = shops; }
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("balance", List.of("bal", "money"), (p, a) -> balance(p)));
out.add(Cmd.of("pay", List.of(), this::pay).completing((p, a) -> a.length <= 1 ? EssentialsCommands.names(env, p) : List.of()));
out.add(Cmd.of("baltop", List.of("richest"), (p, a) -> baltop(p)));
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(p)));
out.add(Cmd.of("sell", List.of(), (p, a) -> sell(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("hand") : List.of()));
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy", "point") : a.length == 2 && a[0].equalsIgnoreCase("point") ? List.of("add", "remove", "list") : List.of()));
out.add(Cmd.of("orders", List.of(), (p, a) -> orders(p)));
out.add(Cmd.of("trade", List.of(), (p, a) -> p.send(Format.RED + "/trade needs the chest window, which isn't available on this platform yet. Use /market or /pay.")));
out.add(Cmd.of("transactions", List.of(), (p, a) -> transactions(p)));
return out;
}
private JsonObject who(CorePlayer p) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid().toString());
o.addProperty("username", p.name());
return o;
}
void balance(CorePlayer p) {
p.send(Format.GRAY + "Fetching your balance...");
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
p.send(Format.GOLD + "=== SCOPENET Economy ===");
p.send(Format.YELLOW + "Your Balance: " + Format.GREEN + env.money(bal));
}, e -> p.send(Format.RED + "Could not reach economy server: " + e));
}
void pay(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /pay <player> <amount>"); return; }
Optional<CorePlayer> found = env.platform.playerByName(args[0]);
if (found.isEmpty()) { p.send(Format.RED + "Player '" + args[0] + "' is not online."); return; }
CorePlayer target = found.get();
if (target.uuid().equals(p.uuid())) { p.send(Format.RED + "You cannot pay yourself."); return; }
Double amount;
try { amount = Double.parseDouble(args[1]); } catch (NumberFormatException e) { amount = null; }
if (amount == null || amount <= 0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
p.send(Format.RED + "Invalid amount specified. Must be positive number.");
return;
}
final double value = amount;
p.send(Format.GRAY + "Processing payment...");
JsonObject body = new JsonObject();
body.addProperty("from_uuid", p.uuid().toString());
body.addProperty("from_name", p.name());
body.addProperty("to_uuid", target.uuid().toString());
body.addProperty("to_name", target.name());
body.addProperty("amount", value);
body.addProperty("description", "Player payment to " + target.name());
env.io(() -> env.panel.call("economy/transfer", body).getAsJsonObject(), r -> {
double left = r.has("from_balance") ? r.get("from_balance").getAsDouble() : 0.0;
p.send(Format.GREEN + "Sent " + Format.YELLOW + env.money(value) + Format.GREEN + " to " + Format.YELLOW + target.name()
+ Format.GREEN + ". New Balance: " + Format.YELLOW + env.money(left));
target.send(Format.GREEN + "Received " + Format.YELLOW + env.money(value) + Format.GREEN + " from " + Format.YELLOW + p.name() + Format.GREEN + "!");
}, e -> p.send(Format.RED + "Transfer failed: " + e));
}
void baltop(CorePlayer p) {
p.send(Format.GRAY + "Loading leaderboard...");
env.io(() -> env.panel.call("economy/baltop", new JsonObject()), el -> {
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
p.send(Format.GOLD + "================= " + Format.YELLOW + "BALTOP (Richest Players)" + Format.GOLD + " =================");
if (list.size() == 0) { p.send(Format.GRAY + "No players found on the leaderboard."); return; }
for (int i = 0; i < Math.min(10, list.size()); i++) {
JsonObject e = list.get(i).getAsJsonObject();
int rank = e.has("rank") ? e.get("rank").getAsInt() : i + 1;
String name = e.has("username") ? e.get("username").getAsString() : "Unknown";
double bal = e.has("balance") ? e.get("balance").getAsDouble() : 0.0;
String colour = rank == 1 ? Format.GOLD : rank == 2 ? Format.WHITE : rank == 3 ? Format.YELLOW : Format.GRAY;
p.send(colour + "#" + rank + " " + Format.WHITE + name + ": " + Format.GREEN + env.money(bal));
}
}, e -> p.send(Format.RED + "Could not fetch baltop: " + e));
}
void shop(CorePlayer p) {
p.send(Format.GOLD + "=== Server Shop (sell prices) ===");
ItemValues.all().forEach((id, price) -> p.send(Format.GRAY + " " + id.toLowerCase(Locale.ROOT).replace('_', ' ') + Format.DARK_GRAY + " - " + Format.GREEN + env.money(price)));
p.send(Format.GRAY + "Hold items and use " + Format.YELLOW + "/sell hand" + Format.GRAY + " to sell them.");
}
/** {@code guild}: the proceeds go to the player's guild bank. */
void sell(CorePlayer p, String[] args, boolean guild) {
if (args.length == 0 || !args[0].equalsIgnoreCase("hand")) {
p.send(Format.YELLOW + "Hold the items you want to sell and use /" + (guild ? "guild sell" : "sell") + " hand." + Format.GRAY + " See prices with /shop.");
return;
}
if (!guild && !p.hasPermission("scopenet.command.sell.hand")) { p.send(Format.RED + "You do not have permission to use /sell hand."); return; }
Optional<Item> held = p.heldItem();
if (held.isEmpty()) { p.send(Format.RED + "You are not holding any item to sell."); return; }
Item item = held.get();
Double unit = ItemValues.unitPrice(item.id());
if (unit == null) { p.send(Format.RED + "This item (" + item.id() + ") cannot be sold to the server shop."); return; }
JsonObject payload = who(p);
payload.addProperty(guild ? "amount" : "delta", unit * item.count());
payload.addProperty("description", "Sell hand: " + item.id());
p.clearHeld();
if (!jobs.enqueue(p, guild ? "guilds/bank/credit" : "economy/adjust", payload, List.of(item), null)) p.give(item);
}
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */
void market(CorePlayer p, String[] args, boolean asGuild) {
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
// The block under the player's feet: the platform-neutral stand-in for "the block you look at".
Pos at = p.pos();
shops.command(p, Arrays.copyOfRange(args, 1, args.length), new Pos(at.world(), at.x(), at.y() - 1, at.z(), 0, 0));
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
if (!asGuild && !p.hasPermission("scopenet.command.market.buy")) { p.send(Format.RED + "You do not have permission to buy from the market."); return; }
long listing;
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
p.send(Format.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as #n in /market)");
return;
}
JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listing);
payload.addProperty("buyer_uuid", p.uuid().toString());
payload.addProperty("buyer_name", p.name());
if (asGuild) payload.addProperty("as_guild", true);
jobs.enqueue(p, "economy/market/buy", payload, List.of(), null);
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
if (!asGuild && !p.hasPermission("scopenet.command.market.sell")) { p.send(Format.RED + "You do not have permission to list items on the market."); return; }
Optional<Item> held = p.heldItem();
if (held.isEmpty()) { p.send(Format.RED + "Hold the item you want to sell in your main hand."); return; }
Double price;
try { price = Double.parseDouble(args[1]); } catch (NumberFormatException e) { price = null; }
if (price == null || price <= 0 || price.isNaN() || price.isInfinite()) { p.send(Format.RED + "Invalid price. Usage: /market sell <price>"); return; }
Item item = held.get();
JsonObject payload = new JsonObject();
payload.addProperty("seller_uuid", p.uuid().toString());
payload.addProperty("seller_name", p.name());
payload.addProperty("item_id", item.id());
payload.addProperty("item_name", item.name());
payload.addProperty("amount", item.count());
payload.addProperty("price", price);
payload.addProperty("item_data", item.data());
if (asGuild) payload.addProperty("as_guild", true);
p.clearHeld(); // escrow first, then a retryable operation
if (!jobs.enqueue(p, "economy/market/list", payload, List.of(item), null)) p.give(item);
return;
}
listMarket(p);
}
void listMarket(CorePlayer p) {
p.send(Format.GRAY + "Loading market listings...");
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
p.send(Format.GOLD + "=== Player Market ===");
if (list.size() == 0) { p.send(Format.GRAY + "No listings. Be the first: hold an item and use /market sell <price>."); return; }
for (JsonElement e : list) {
JsonObject l = e.getAsJsonObject();
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? l.get("seller_guild").getAsString() : null;
String seller = guild != null ? Format.GOLD + "[" + guild + "] guild" : Format.WHITE + l.get("seller_name").getAsString();
p.send(Format.DARK_GRAY + "#" + l.get("id").getAsLong() + " " + Format.WHITE + l.get("item_name").getAsString() + Format.GRAY + " x" + l.get("amount").getAsInt()
+ " " + Format.GREEN + env.money(l.get("price").getAsDouble()) + Format.GRAY + " by " + seller);
}
p.send(Format.GRAY + "Buy with " + Format.YELLOW + "/market buy <#id>");
}, e -> p.send(Format.RED + "Could not load market listings: " + e));
}
void orders(CorePlayer p) {
p.send(Format.GOLD + "=== Your Active Market Orders ===");
p.send(Format.GRAY + "Use " + Format.YELLOW + "/market sell <price>" + Format.GRAY + " to list items, or browse with " + Format.YELLOW + "/market" + Format.GRAY + ".");
}
void transactions(CorePlayer p) {
p.send(Format.GOLD + "=== Recent Transactions ===");
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
p.send(Format.GREEN + "Current Balance: " + Format.YELLOW + env.money(bal));
p.send(Format.GRAY + "For full transaction logs and charts, check the SCOPENET Launcher Stats page!");
}, e -> p.send(Format.RED + "Error: " + e));
}
}
@@ -0,0 +1,39 @@
package net.scopenet.core;
import java.nio.file.Path;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
import java.util.logging.Logger;
/** Everything a command needs: the platform, the panel, settings and a way to do network work safely. */
public final class Env {
public final Platform platform;
public final Panel panel;
public volatile Features features;
public final Path dataDir;
public final LongSupplier clock;
public final Random rng;
public final Logger log;
public Env(Platform platform, Panel panel, Features features, Path dataDir, LongSupplier clock, Random rng, Logger log) {
this.platform = platform; this.panel = panel; this.features = features; this.dataDir = dataDir;
this.clock = clock; this.rng = rng; this.log = log;
}
/** Do {@code work} off the server thread, then call back on it with the result or an error message. */
public <T> void io(Callable<T> work, Consumer<T> ok, Consumer<String> fail) {
platform.runAsync(() -> {
try {
T result = work.call();
platform.runMain(() -> ok.accept(result));
} catch (Exception e) {
String message = e.getMessage() != null && !e.getMessage().isBlank() ? e.getMessage() : e.getClass().getSimpleName();
platform.runMain(() -> fail.accept(message));
}
});
}
public String money(double value) { return Format.money(features.currencySymbol(), value); }
}
@@ -0,0 +1,51 @@
package net.scopenet.core;
import java.util.*;
/** /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime. */
final class EssentialsCommands {
private EssentialsCommands() {}
static List<CoreCommand> build(Env env, EssentialsService ess) {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("spawn", List.of("hub", "lobby"), (p, a) -> {
Pos spawn = env.platform.worldSpawn(p.pos().world());
ess.teleportTo(p, new Pos(spawn.world(), spawn.x() + 0.5, spawn.y(), spawn.z() + 0.5, spawn.yaw(), spawn.pitch()));
p.send(Format.GREEN + "Teleported to spawn.");
}));
out.add(Cmd.of("home", List.of("homes"), (p, a) -> {
List<String> choices = ess.home(p, a);
if (!choices.isEmpty()) p.send(Format.GOLD + "Your homes: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
+ Format.GRAY + " (use /home <name>)");
}).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
out.add(Cmd.of("sethome", List.of("createshome"), ess::setHome));
out.add(Cmd.of("delhome", List.of("rmhome"), ess::delHome).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
out.add(Cmd.of("back", List.of("return"), (p, a) -> ess.back(p)));
out.add(Cmd.of("tpa", List.of(), ess::tpa).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
out.add(Cmd.of("tpaccept", List.of("tpyes"), (p, a) -> ess.tpAccept(p)));
out.add(Cmd.of("tpdeny", List.of("tpno"), (p, a) -> ess.tpDeny(p)));
out.add(Cmd.of("rtp", List.of("wild", "randomtp"), (p, a) -> ess.rtp(p)));
out.add(Cmd.of("warp", List.of("warps"), (p, a) -> {
List<String> choices = ess.warp(p, a);
if (!choices.isEmpty()) p.send(Format.AQUA + "Warps: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
+ Format.GRAY + " (use /warp <name>)");
}).completing((p, a) -> a.length <= 1 ? ess.warpNames() : List.of()));
out.add(Cmd.of("playtime", List.of("ontime"), (p, a) -> {
CorePlayer target = p;
if (a.length > 0) {
Optional<CorePlayer> found = env.platform.playerByName(a[0]);
if (found.isEmpty()) { p.send(Format.RED + "Player not found."); return; }
target = found.get();
}
p.send(Format.GOLD + "=== Playtime: " + Format.YELLOW + target.name() + Format.GOLD + " ===");
p.send(Format.GRAY + "Total Time: " + Format.GREEN + Format.duration(target.playtimeSeconds()));
}).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
return out;
}
static List<String> names(Env env, CorePlayer except) {
List<String> names = new ArrayList<>();
for (CorePlayer p : env.platform.online()) if (!p.uuid().equals(except.uuid())) names.add(p.name());
return names;
}
}
@@ -0,0 +1,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,249 @@
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 -------------------------------------------------------------
/** Every warp, for the map. A copy. */
public Map<String, Pos> allWarps() { return new TreeMap<>(warps); }
/** Every player's homes, for the map (only shown when the admin turns that on). A copy. */
public Map<UUID, Map<String, Pos>> allHomes() {
Map<UUID, Map<String, Pos>> out = new HashMap<>();
homes.forEach((id, h) -> out.put(id, new TreeMap<>(h)));
return out;
}
public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); }
public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); }
/** {@code /warp [name]}. With no name, returns the list for a picker (seeding "spawn" if empty). */
public List<String> warp(CorePlayer p, String[] args) {
if (args.length == 0) {
if (warps.isEmpty()) { warps.put("spawn", platform.worldSpawn(p.pos().world())); save(); }
return warpNames();
}
String name = args[0].toLowerCase(Locale.ROOT);
Pos pos = warps.get(name);
if (pos == null) { p.send(RED + "Warp '" + name + "' does not exist."); return List.of(); }
go(p, pos);
p.send(GREEN + "Teleported to warp " + YELLOW + name + GREEN + ".");
return List.of();
}
public void setWarp(CorePlayer p, String name) {
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,331 @@
package net.scopenet.core;
import com.google.gson.*;
import net.scopenet.integration.ChunkCheckResult;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
final class GuildCommands {
private static final Gson GSON = new Gson();
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline");
private final Env env;
private final Jobs jobs;
private final EconomyCommands economy;
private final EssentialsService ess;
private final Path homesFile;
private final Map<String, Pos> guildHomes = new ConcurrentHashMap<>();
GuildCommands(Env env, Jobs jobs, EconomyCommands economy, EssentialsService ess, Path homesFile) {
this.env = env; this.jobs = jobs; this.economy = economy; this.ess = ess; this.homesFile = homesFile;
if (Files.exists(homesFile)) {
try (Reader in = Files.newBufferedReader(homesFile)) {
Map<String, Pos> loaded = GSON.fromJson(in, new com.google.gson.reflect.TypeToken<Map<String, Pos>>() {}.getType());
if (loaded != null) guildHomes.putAll(loaded);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + homesFile + ": " + e.getMessage(), e);
}
}
}
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline")
: a.length == 2 && a[0].equalsIgnoreCase("invite") ? EssentialsCommands.names(env, p) : List.of()));
out.add(Cmd.of("claim", List.of(), (p, a) -> claim(p)));
out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p)));
return out;
}
private void guild(CorePlayer p, String[] args) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
if (!p.hasPermission("scopenet.command.guild.info")) { p.send(Format.RED + "You do not have permission to use /guild info."); return; }
info(p);
return;
}
String sub = args[0].toLowerCase(Locale.ROOT);
String node = sub.equals("c") ? "chat" : sub;
if (GATED.contains(node) && !p.hasPermission("scopenet.command.guild." + node)) {
p.send(Format.RED + "You do not have permission to use /guild " + node + ".");
return;
}
String detail = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
String fine = sub.equals("sell") && detail.equals("hand") ? "sell.hand"
: sub.equals("market") && (detail.equals("sell") || detail.equals("buy")) ? "market." + detail : null;
if (fine != null && !p.hasPermission("scopenet.command.guild." + fine)) {
p.send(Format.RED + "You do not have permission to use /guild " + sub + " " + detail + ".");
return;
}
String[] rest = Arrays.copyOfRange(args, 1, args.length);
switch (sub) {
case "create" -> create(p, args);
case "leave" -> leave(p);
case "claim" -> claim(p);
case "unclaim" -> unclaim(p);
case "map" -> map(p);
case "chat", "c" -> chat(p, args);
case "sethome" -> setHome(p);
case "home" -> home(p);
case "members" -> members(p);
case "bank" -> bank(p, rest);
case "pay" -> pay(p, rest);
case "invite" -> invite(p, rest);
case "accept" -> answer(p, rest, true);
case "decline", "deny" -> answer(p, rest, false);
case "sell" -> economy.sell(p, rest, true);
case "market" -> economy.market(p, rest, true);
default -> help(p);
}
}
private void help(CorePlayer p) {
p.send(Format.GOLD + "=== SCOPENET Guild Commands ===");
String[][] rows = {
{"/guild", "Your guild overview"}, {"/guild create <name> <tag>", "Create a new guild"}, {"/guild leave", "Leave your current guild"},
{"/claim", "Claim current chunk (also /guild claim)"}, {"/unclaim", "Unclaim current chunk (also /guild unclaim)"},
{"/guild map", "Show nearby land claims"}, {"/guild chat <msg>", "Send message to guild members"},
{"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw <amount>]", "Guild money"},
{"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell <price> | buy <#id>", "Trade on the market as a guild"},
{"/guild pay <tag> <amount>", "Pay another guild"},
{"/guild invite <player>", "Invite a player (leaders and officers)"}, {"/guild accept [tag] | decline [tag]", "Answer a guild invitation"},
};
for (String[] r : rows) p.send(Format.YELLOW + r[0] + Format.GRAY + " - " + r[1]);
}
private JsonObject uuidBody(CorePlayer p) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid().toString());
return o;
}
private JsonObject chunkBody(CorePlayer p) {
Pos pos = p.pos();
JsonObject o = uuidBody(p);
o.addProperty("dimension", pos.world());
o.addProperty("chunk_x", pos.blockX() >> 4);
o.addProperty("chunk_z", pos.blockZ() >> 4);
return o;
}
void claim(CorePlayer p) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
JsonObject body = chunkBody(p);
p.send(Format.GRAY + "Claiming chunk [" + body.get("chunk_x").getAsInt() + ", " + body.get("chunk_z").getAsInt() + "]...");
env.io(() -> { JsonObject r = env.panel.call("guilds/claim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
String name = r.has("guild_name") ? r.get("guild_name").getAsString() : "your guild";
p.send(Format.GREEN + "Chunk claimed successfully for " + Format.YELLOW + name + Format.GREEN + "!");
}, e -> p.send(Format.RED + "Claim failed: " + e));
}
void unclaim(CorePlayer p) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
JsonObject body = chunkBody(p);
int cx = body.get("chunk_x").getAsInt(), cz = body.get("chunk_z").getAsInt();
p.send(Format.GRAY + "Unclaiming chunk [" + cx + ", " + cz + "]...");
env.io(() -> { JsonObject r = env.panel.call("guilds/unclaim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
r -> p.send(Format.GREEN + "Chunk [" + cx + ", " + cz + "] has been unclaimed."), e -> p.send(Format.RED + "Unclaim failed: " + e));
}
private void create(CorePlayer p, String[] args) {
if (args.length < 3) { p.send(Format.RED + "Usage: /guild create <name> <tag>"); return; }
String name = args[1], tag = args[2];
p.send(Format.GRAY + "Founding guild " + name + " [" + tag + "]...");
JsonObject body = uuidBody(p);
body.addProperty("username", p.name());
body.addProperty("name", name);
body.addProperty("tag", tag);
env.io(() -> { JsonObject r = env.panel.call("guilds/create", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
r -> p.send(Format.GREEN + "Guild " + Format.YELLOW + name + Format.GOLD + " [" + tag + "]" + Format.GREEN + " founded successfully! You are the Guild Leader."),
e -> p.send(Format.RED + "Failed to create guild: " + e));
}
private void leave(CorePlayer p) {
p.send(Format.GRAY + "Leaving guild...");
env.io(() -> { JsonObject r = env.panel.call("guilds/leave", uuidBody(p)).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
boolean disbanded = r.has("disbanded") && r.get("disbanded").getAsBoolean();
p.send(disbanded ? Format.YELLOW + "You were the last member. The guild was disbanded." : Format.GREEN + "You left your guild.");
}, e -> p.send(Format.RED + "Error leaving guild: " + e));
}
/** The guild the player is in, or an error message sent to them. Runs {@code then} on the server thread. */
private void withGuild(CorePlayer p, String notInGuild, java.util.function.Consumer<JsonObject> then) {
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) { p.send(Format.RED + notInGuild); return; }
then.accept(r.getAsJsonObject("guild"));
}, e -> p.send(Format.RED + "Error: " + e));
}
private void info(CorePlayer p) {
p.send(Format.GRAY + "Loading guild info...");
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) {
p.send(Format.YELLOW + "You are not in a guild! Create one with " + Format.GOLD + "/guild create <name> <tag>");
return;
}
JsonObject g = r.getAsJsonObject("guild");
int members = g.has("members") ? g.getAsJsonArray("members").size() : 0;
p.send(Format.GOLD + "=== Guild: " + Format.YELLOW + g.get("name").getAsString() + " " + Format.GOLD + "[" + g.get("tag").getAsString() + "] ===");
p.send(Format.GRAY + "Your role: " + Format.GREEN + g.get("role").getAsString().toUpperCase(Locale.ROOT) + Format.GRAY + " Level: " + Format.GOLD
+ (g.has("level") ? g.get("level").getAsInt() : 1) + Format.GRAY + " Members: " + Format.WHITE + members);
p.send(Format.GRAY + "Claimed chunks: " + Format.YELLOW + (g.has("claims_count") ? g.get("claims_count").getAsLong() : 0)
+ Format.GRAY + " / " + Format.GREEN + (g.has("max_claims") ? g.get("max_claims").getAsLong() : 16));
p.send(Format.GRAY + "/claim, /unclaim, /guild map, /guild members, /guild bank");
}, e -> p.send(Format.RED + "Failed to load guild: " + e));
}
private void map(CorePlayer p) {
Pos pos = p.pos();
int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4;
p.send(Format.GOLD + "========= " + Format.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + Format.GOLD + " =========");
p.send(Format.GRAY + " N (-Z)");
for (int dz = -3; dz <= 3; dz++) {
StringBuilder row = new StringBuilder(" ");
for (int dx = -4; dx <= 4; dx++) {
if (dx == 0 && dz == 0) { row.append(Format.GOLD).append("[P]"); continue; }
ChunkCheckResult res = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid());
if (res.claimed() && res.allowed()) row.append(Format.GREEN).append(" + ");
else if (res.claimed()) row.append(Format.RED).append(" x ");
else row.append(Format.DARK_GRAY).append(" . ");
}
p.send(row.toString());
}
p.send(Format.GRAY + " S (+Z)");
p.send(Format.GOLD + "[P]" + Format.YELLOW + " You " + Format.GREEN + "+ " + Format.GRAY + "Your Guild " + Format.RED + "x " + Format.GRAY + "Other Guild "
+ Format.DARK_GRAY + ". " + Format.GRAY + "Wilderness");
}
private void chat(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /guild chat <message>"); return; }
String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
withGuild(p, "You are not in a guild.", g -> {
String tag = g.get("tag").getAsString();
Set<String> names = new HashSet<>();
if (g.has("members")) g.getAsJsonArray("members").forEach(m -> names.add(m.getAsJsonObject().get("name").getAsString().toLowerCase(Locale.ROOT)));
String line = Format.GREEN + "[Guild " + tag + "] " + Format.YELLOW + p.name() + ": " + Format.WHITE + message;
// Only guild members read guild chat.
for (CorePlayer other : env.platform.online()) if (names.contains(other.name().toLowerCase(Locale.ROOT)) || other.uuid().equals(p.uuid())) other.send(line);
});
}
private void save() {
try {
if (homesFile.getParent() != null) Files.createDirectories(homesFile.getParent());
Path tmp = homesFile.resolveSibling(homesFile.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(new TreeMap<>(guildHomes), w); }
Files.move(tmp, homesFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
env.log.warning("Failed to save guild homes: " + e.getMessage());
}
}
private void setHome(CorePlayer p) {
withGuild(p, "You must be in a guild to set a guild home.", g -> {
String role = g.get("role").getAsString();
if (!role.equals("leader") && !role.equals("officer")) { p.send(Format.RED + "Only guild leaders and officers can set the guild home."); return; }
guildHomes.put(g.get("id").getAsString(), p.pos());
save();
p.send(Format.GREEN + "Guild home waypoint set at your location!");
});
}
private void home(CorePlayer p) {
withGuild(p, "You are not in a guild.", g -> {
Pos at = guildHomes.get(g.get("id").getAsString());
if (at == null) { p.send(Format.RED + "Your guild has not set a guild home yet! Use /guild sethome"); return; }
ess.teleportTo(p, at);
p.send(Format.GREEN + "Teleported to guild home base.");
});
}
private void members(CorePlayer p) {
withGuild(p, "You are not in a guild.", g -> {
p.send(Format.GOLD + "=== Members of " + Format.YELLOW + g.get("name").getAsString() + Format.GOLD + " ===");
for (JsonElement el : g.getAsJsonArray("members")) {
JsonObject m = el.getAsJsonObject();
String name = m.get("name").getAsString();
String status = env.platform.playerByName(name).isPresent() ? Format.GREEN + "[Online]" : Format.GRAY + "[Offline]";
p.send(Format.WHITE + " - " + name + " (" + Format.AQUA + m.get("role").getAsString() + Format.WHITE + ") " + status);
}
});
}
// ---- invitations ------------------------------------------------------------
private void invite(CorePlayer p, String[] args) {
if (args.length < 1) { p.send(Format.RED + "Usage: /guild invite <player>"); return; }
JsonObject body = uuidBody(p);
body.addProperty("target", args[0]);
env.io(() -> env.panel.call("guilds/invite/send", body).getAsJsonObject(),
r -> p.send(Format.GREEN + "Invitation sent to " + Format.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[0]) + Format.GREEN + "."),
e -> p.send(Format.RED + "Could not invite: " + e));
}
private void answer(CorePlayer p, String[] args, boolean accept) {
JsonObject body = uuidBody(p);
body.addProperty("tag", args.length > 0 ? args[0] : "");
body.addProperty("accept", accept);
env.io(() -> { JsonObject r = env.panel.call("guilds/invite/respond", body).getAsJsonObject(); if (accept) env.panel.claimsChanged(); return r; },
r -> p.send(accept ? Format.GREEN + "Welcome to " + Format.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + Format.GREEN + "!"
: Format.YELLOW + "Invitation declined."),
e -> p.send(Format.RED + e));
}
/** Guild homes by guild id, for the map. */
Map<String, Pos> guildHomes() { return Map.copyOf(guildHomes); }
// ---- bank ---------------------------------------------------------------
private void bank(CorePlayer p, String[] args) {
if (args.length >= 2 && (args[0].equalsIgnoreCase("deposit") || args[0].equalsIgnoreCase("withdraw"))) {
String action = args[0].toLowerCase(Locale.ROOT);
if (!p.hasPermission("scopenet.command.guild.bank." + action)) { p.send(Format.RED + "You do not have permission to " + action + " guild money."); return; }
Double value = Format.amount(args[1]);
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
JsonObject payload = uuidBody(p);
payload.addProperty("username", p.name());
payload.addProperty("amount", value);
payload.addProperty("action", action);
jobs.enqueue(p, "guilds/bank/transfer", payload, List.of(), null);
return;
}
if (args.length > 0 && !args[0].equalsIgnoreCase("balance") && !args[0].equalsIgnoreCase("info")) {
p.send(Format.RED + "Usage: /guild bank [deposit|withdraw <amount>]");
return;
}
env.io(() -> env.panel.call("guilds/bank", uuidBody(p)).getAsJsonObject(), info -> {
if (!info.has("guild") || info.get("guild").isJsonNull()) { p.send(Format.RED + "You are not in a guild on this server."); return; }
JsonObject g = info.getAsJsonObject("guild");
p.send(Format.GOLD + "=== [" + g.get("tag").getAsString() + "] " + g.get("name").getAsString() + " Bank ===");
p.send(Format.YELLOW + "Balance: " + Format.GREEN + env.money(info.get("balance").getAsDouble()) + Format.GRAY + " Your wallet: "
+ env.money(info.get("my_balance").getAsDouble()) + " Role: " + info.get("role").getAsString());
for (JsonElement el : info.getAsJsonArray("recent")) {
JsonObject row = el.getAsJsonObject();
String kind = row.get("kind").getAsString();
boolean out = kind.equals("withdraw") || kind.equals("purchase") || kind.equals("transfer_out");
p.send(Format.GRAY + " " + (out ? Format.RED + "-" : Format.GREEN + "+") + env.money(row.get("amount").getAsDouble()) + Format.GRAY + " "
+ kind.replace('_', ' ') + " by " + row.get("who").getAsString());
}
p.send(Format.GRAY + "/guild bank deposit|withdraw <amount>, /guild sell, /guild market, /guild pay <tag> <amount>");
}, e -> p.send(Format.RED + "Could not load the guild bank: " + e));
}
private void pay(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /guild pay <guild tag> <amount>"); return; }
Double value = Format.amount(args[1]);
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
JsonObject payload = uuidBody(p);
payload.addProperty("to_tag", args[0]);
payload.addProperty("amount", value);
jobs.enqueue(p, "guilds/bank/pay", payload, List.of(), null);
}
}
@@ -0,0 +1,19 @@
package net.scopenet.core;
/**
* A stack of items in a platform-neutral form. {@code id} is what the panel stores ("DIAMOND", or
* "modid:item" for modded items); {@code data} is the platform's own exact serialisation (used to return
* or deliver the very same stack). When {@code data} is empty the platform rebuilds the stack from id and count.
*/
public record Item(String id, String name, int count, String data) {
/** Panel id for a registry id: minecraft items become "DIAMOND", others stay "modid:item". */
public static String panelId(String registryId) {
String s = registryId.toLowerCase(java.util.Locale.ROOT);
return s.startsWith("minecraft:") ? s.substring("minecraft:".length()).toUpperCase(java.util.Locale.ROOT) : s;
}
/** The registry id for a panel id (inverse of {@link #panelId}). */
public static String registryId(String panelId) {
return panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,26 @@
package net.scopenet.core;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/** What the server shop pays per item (same table as the Paper plugin). Keys are panel item ids. */
public final class ItemValues {
private static final Map<String, Double> VALUES = new TreeMap<>();
static {
Object[][] table = {
{"COBBLESTONE", 0.1}, {"STONE", 0.2}, {"OAK_LOG", 0.5}, {"BIRCH_LOG", 0.5}, {"SPRUCE_LOG", 0.5}, {"COAL", 1.0},
{"RAW_COPPER", 1.5}, {"COPPER_INGOT", 2.0}, {"RAW_IRON", 3.0}, {"IRON_INGOT", 4.0}, {"RAW_GOLD", 6.0},
{"GOLD_INGOT", 8.0}, {"REDSTONE", 1.5}, {"LAPIS_LAZULI", 2.0}, {"DIAMOND", 35.0}, {"EMERALD", 20.0},
{"NETHERITE_SCRAP", 150.0}, {"NETHERITE_INGOT", 650.0}, {"WHEAT", 0.5}, {"CARROT", 0.5}, {"POTATO", 0.5},
{"BEEF", 1.0}, {"COOKED_BEEF", 2.0}, {"PORKCHOP", 1.0}, {"COOKED_PORKCHOP", 2.0},
};
for (Object[] row : table) VALUES.put((String) row[0], (Double) row[1]);
}
private ItemValues() {}
public static Double unitPrice(String panelId) { return VALUES.get(panelId.toUpperCase(Locale.ROOT)); }
public static Map<String, Double> all() { return java.util.Collections.unmodifiableMap(VALUES); }
}
@@ -0,0 +1,158 @@
package net.scopenet.core;
import com.google.gson.*;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
/**
* Money-and-items transactions that must never lose either. The held items are taken and written to disk first
* ("escrow"), the panel call carries an operation id so a retry can't double-pay, and the items are only
* returned or delivered once the panel has answered. Survives crashes and panel outages. Server thread only.
*/
public final class Jobs {
private final Env env;
private final Path file;
private final Map<String, JsonObject> jobs = new LinkedHashMap<>();
private final Set<String> running = new HashSet<>();
public Jobs(Env env, Path file) {
this.env = env;
this.file = file;
if (Files.exists(file)) {
try (Reader in = Files.newBufferedReader(file)) {
JsonObject all = JsonParser.parseReader(in).getAsJsonObject();
all.entrySet().forEach(e -> jobs.put(e.getKey(), e.getValue().getAsJsonObject()));
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
}
}
}
public int pending() { return jobs.size(); }
private static JsonObject encode(Item item) {
JsonObject o = new JsonObject();
o.addProperty("id", item.id());
o.addProperty("name", item.name());
o.addProperty("count", item.count());
o.addProperty("data", item.data());
return o;
}
private static Item decode(JsonElement e) {
JsonObject o = e.getAsJsonObject();
return new Item(o.get("id").getAsString(), o.get("name").getAsString(), o.get("count").getAsInt(), o.get("data").getAsString());
}
/**
* Queue {@code endpoint} with {@code payload}. {@code escrow} were taken from the player and are returned if the
* panel refuses. {@code purchase} is delivered once it accepts. Returns false (and queues nothing) if it can't be saved.
*/
public boolean enqueue(CorePlayer player, String endpoint, JsonObject payload, List<Item> escrow, Item purchase) {
String id = UUID.randomUUID().toString();
payload.addProperty("operation_id", id);
JsonObject job = new JsonObject();
job.addProperty("player", player.uuid().toString());
job.addProperty("endpoint", endpoint);
job.add("payload", payload);
JsonArray held = new JsonArray();
escrow.forEach(i -> held.add(encode(i)));
job.add("escrow", held);
if (purchase != null) job.add("purchase", encode(purchase));
jobs.put(id, job);
try { save(); } catch (IOException e) {
jobs.remove(id);
player.send(Format.RED + "Cannot save transaction; nothing was purchased or sold.");
return false;
}
player.send(Format.GRAY + "Transaction queued. Items are kept safe while the panel responds.");
return true;
}
private void save() throws IOException {
JsonObject all = new JsonObject();
jobs.forEach(all::add);
if (file.getParent() != null) Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { w.write(all.toString()); }
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
/** Call every few seconds on the server thread. Sends waiting jobs and delivers finished ones. */
public void tick() {
for (Map.Entry<String, JsonObject> entry : new ArrayList<>(jobs.entrySet())) {
String id = entry.getKey();
JsonObject job = entry.getValue();
if (job.has("result") || job.has("error")) { deliver(id, job); continue; }
if (!running.add(id)) continue;
String endpoint = job.get("endpoint").getAsString();
JsonObject payload = job.getAsJsonObject("payload").deepCopy();
env.platform.runAsync(() -> {
JsonElement result = null;
String error = null;
try { result = env.panel.call(endpoint, payload); }
catch (net.scopenet.integration.PanelClient.HttpFailure e) { if (e.status >= 400 && e.status < 500) error = e.getMessage(); }
catch (Exception e) { /* Unknown outcome: retry the same operation id; a credit is never duplicated. */ }
final JsonElement response = result;
final String failure = error;
env.platform.runMain(() -> {
running.remove(id);
if (response != null && response.isJsonObject()) job.add("result", response);
if (failure != null) job.addProperty("error", failure);
try { save(); } catch (IOException e) { env.log.severe("Could not persist economy result: " + e.getMessage()); return; }
if (job.has("result") || job.has("error")) deliver(id, job);
});
});
}
}
private void deliver(String id, JsonObject job) {
Optional<CorePlayer> found = env.platform.player(UUID.fromString(job.get("player").getAsString()));
if (found.isEmpty()) return; // waits until they are back
CorePlayer player = found.get();
try {
List<Item> items = new ArrayList<>();
boolean failed = job.has("error");
String endpoint = job.get("endpoint").getAsString();
if (failed) {
job.getAsJsonArray("escrow").forEach(e -> items.add(decode(e)));
} else if (job.has("purchase")) {
items.add(decode(job.get("purchase")));
} else if (endpoint.equals("economy/market/buy")) {
JsonObject result = job.getAsJsonObject("result");
if (result.has("item_data") && !result.get("item_data").isJsonNull() && !result.get("item_data").getAsString().isEmpty()) {
// Another platform's exact stack can't be rebuilt here, so fall back to id and count.
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(),
result.get("item_data").getAsString()));
} else {
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(), ""));
}
}
items.forEach(player::give);
jobs.remove(id);
save();
String symbol = env.features.currencySymbol();
if (failed) {
player.send(Format.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
return;
}
JsonObject result = job.getAsJsonObject("result");
if (result.has("message") && result.get("message").isJsonPrimitive()) {
JsonElement bal = result.get("balance");
String bank = bal != null && bal.isJsonPrimitive() ? " Bank: " + Format.money(symbol, bal.getAsDouble()) : "";
player.send(Format.GREEN + result.get("message").getAsString() + bank);
} else {
JsonElement bal = result.has("new_balance") ? result.get("new_balance") : result.get("balance");
player.send(Format.GREEN + "Transaction completed." + (bal != null && bal.isJsonPrimitive() ? " Balance: " + Format.money(symbol, bal.getAsDouble()) : ""));
}
} catch (Exception e) {
env.log.severe("Economy delivery requires attention for " + id + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,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 -----------------------------
public static JsonObject obj(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
}
public static double num(JsonObject o, String key, double fallback) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : fallback;
}
public static String str(JsonObject o, String key, String fallback) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback;
}
public static JsonArray arr(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray();
}
}
@@ -0,0 +1,8 @@
package net.scopenet.core;
/** A spot in a world, independent of any server software. {@code world} is the dimension id, e.g. "minecraft:overworld". */
public record Pos(String world, double x, double y, double z, float yaw, float pitch) {
public int blockX() { return (int) Math.floor(x); }
public int blockY() { return (int) Math.floor(y); }
public int blockZ() { return (int) Math.floor(z); }
}
@@ -0,0 +1,99 @@
package net.scopenet.core;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Physical shops: a block in the world that opens the market or the server shop when a player right-clicks it, and
* shows up as a pin on the map. The GUI itself is the same as typing the command, so it works on Paper (chest window)
* and with the client mod (shop and market windows). Set up by staff with {@code /market point add|remove|list}.
*/
public final class ShopPoints {
public record Shop(String name, String kind, Pos pos) {}
public static final String NODE = "scopenet.command.market.point";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path file;
private final Map<String, Shop> shops = new ConcurrentHashMap<>();
public ShopPoints(Path file) {
this.file = file;
if (Files.exists(file)) {
try (Reader in = Files.newBufferedReader(file)) {
List<Shop> loaded = GSON.fromJson(in, new TypeToken<List<Shop>>() {}.getType());
if (loaded != null) loaded.forEach(s -> shops.put(s.name().toLowerCase(Locale.ROOT), s));
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
}
}
}
public Collection<Shop> all() { return new ArrayList<>(new TreeMap<>(shops).values()); }
/** The shop at this block, if any. */
public Optional<Shop> at(String dimension, int x, int y, int z) {
for (Shop s : shops.values()) {
Pos p = s.pos();
if (p.world().equals(dimension) && p.blockX() == x && p.blockY() == y && p.blockZ() == z) return Optional.of(s);
}
return Optional.empty();
}
private void save() {
try {
if (file.getParent() != null) Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(all(), w); }
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
}
}
/**
* {@code /market point add <name> [market|shop]} marks the block the player is looking at... or, where the platform can't
* tell, the block they stand on. {@code target} is that block. {@code remove <name>} and {@code list} as expected.
*/
public void command(CorePlayer p, String[] args, Pos target) {
if (!p.hasPermission(NODE)) { p.send(Format.RED + "You do not have permission to manage shop points."); return; }
String sub = args.length == 0 ? "list" : args[0].toLowerCase(Locale.ROOT);
switch (sub) {
case "add" -> {
if (args.length < 2) { p.send(Format.RED + "Usage: /market point add <name> [market|shop]"); return; }
String name = args[1];
if (!name.matches("[A-Za-z0-9_-]{1,24}")) { p.send(Format.RED + "Names are 1-24 letters, digits, - or _."); return; }
String kind = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : "market";
if (!kind.equals("market") && !kind.equals("shop")) { p.send(Format.RED + "The kind is market or shop."); return; }
if (at(target.world(), target.blockX(), target.blockY(), target.blockZ()).isPresent()) { p.send(Format.RED + "There is already a shop point on that block."); return; }
Pos block = new Pos(target.world(), target.blockX() + 0.5, target.blockY(), target.blockZ() + 0.5, 0, 0);
shops.put(name.toLowerCase(Locale.ROOT), new Shop(name, kind, block));
save();
p.send(Format.GREEN + "Shop point " + Format.YELLOW + name + Format.GREEN + " set (" + kind + "). Right-click that block to open the "
+ (kind.equals("market") ? "market" : "shop") + ".");
}
case "remove", "delete" -> {
if (args.length < 2) { p.send(Format.RED + "Usage: /market point remove <name>"); return; }
if (shops.remove(args[1].toLowerCase(Locale.ROOT)) == null) { p.send(Format.RED + "No shop point called '" + args[1] + "'."); return; }
save();
p.send(Format.GREEN + "Shop point " + Format.YELLOW + args[1] + Format.GREEN + " removed.");
}
case "list" -> {
p.send(Format.GOLD + "=== Shop points ===");
if (shops.isEmpty()) p.send(Format.GRAY + "None yet. Look at a block and use /market point add <name>.");
for (Shop s : all()) p.send(Format.YELLOW + s.name() + Format.GRAY + " (" + s.kind() + ") " + s.pos().blockX() + ", " + s.pos().blockY() + ", " + s.pos().blockZ());
}
default -> p.send(Format.RED + "Usage: /market point add <name> [market|shop] | remove <name> | list");
}
}
}
@@ -0,0 +1,38 @@
package net.scopenet.core;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/** Encoding of client-link messages: a Minecraft "UTF string" (VarInt byte length, then UTF-8), same as FriendlyByteBuf.writeUtf. */
public final class Wire {
public static final String S2C = "scopenet:s2c";
public static final String C2S = "scopenet:c2s";
public static final int VERSION = 1;
private static final int MAX_BYTES = 32767 * 3;
private Wire() {}
public static byte[] encode(String json) {
byte[] body = json.getBytes(StandardCharsets.UTF_8);
if (body.length > MAX_BYTES) throw new IllegalArgumentException("message too large");
ByteArrayOutputStream out = new ByteArrayOutputStream(body.length + 5);
int v = body.length;
while ((v & ~0x7F) != 0) { out.write((v & 0x7F) | 0x80); v >>>= 7; }
out.write(v);
out.writeBytes(body);
return out.toByteArray();
}
public static String decode(byte[] data) {
int length = 0, shift = 0, at = 0;
while (true) {
if (at >= data.length || shift > 28) throw new IllegalArgumentException("bad length");
int b = data[at++];
length |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
if (length < 0 || length > MAX_BYTES || at + length > data.length) throw new IllegalArgumentException("bad length");
return new String(data, at, length, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,172 @@
package net.scopenet.core.map;
import java.util.*;
/**
* Turns a set of claimed chunks into clean outlines for a map: one outer ring per connected area, plus a ring for
* every hole inside it. Coordinates are block coordinates (chunk x 16). Pure and fast: each chunk adds at most four edges.
*/
public final class ChunkRegions {
private ChunkRegions() {}
public record Point(double x, double z) {}
/** A closed loop; the last point connects back to the first. */
public record Ring(List<Point> points) {
public double area() {
double sum = 0;
for (int i = 0; i < points.size(); i++) {
Point a = points.get(i), b = points.get((i + 1) % points.size());
sum += a.x() * b.z() - b.x() * a.z();
}
return sum / 2;
}
public boolean contains(double x, double z) {
boolean inside = false;
for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) {
Point a = points.get(i), b = points.get(j);
if ((a.z() > z) != (b.z() > z) && x < (b.x() - a.x()) * (z - a.z()) / (b.z() - a.z()) + a.x()) inside = !inside;
}
return inside;
}
}
/** One connected area: its outline, the holes in it, how many chunks it has and a good spot for its label. */
public record Region(Ring outer, List<Ring> holes, int chunks, double labelX, double labelZ) {}
public static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
static int kx(long key) { return (int) (key >> 32); }
static int kz(long key) { return (int) key; }
/** Split into areas that touch along an edge. */
static List<Set<Long>> components(Set<Long> chunks) {
List<Set<Long>> out = new ArrayList<>();
Set<Long> seen = new HashSet<>();
List<Long> sorted = new ArrayList<>(chunks);
Collections.sort(sorted);
for (long start : sorted) {
if (!seen.add(start)) continue;
Set<Long> group = new HashSet<>();
Deque<Long> queue = new ArrayDeque<>();
queue.add(start);
group.add(start);
while (!queue.isEmpty()) {
long c = queue.poll();
int x = kx(c), z = kz(c);
for (long n : new long[]{key(x + 1, z), key(x - 1, z), key(x, z + 1), key(x, z - 1)}) {
if (chunks.contains(n) && seen.add(n)) { group.add(n); queue.add(n); }
}
}
out.add(group);
}
return out;
}
public static List<Region> regions(Set<Long> chunks) {
List<Region> out = new ArrayList<>();
for (Set<Long> group : components(chunks)) {
List<Ring> outers = new ArrayList<>(), holes = new ArrayList<>();
for (Ring r : outline(group)) (r.area() > 0 ? outers : holes).add(r);
double[] label = labelSpot(group);
for (Ring o : outers) {
List<Ring> mine = new ArrayList<>();
for (Ring h : holes) if (outers.size() == 1 || o.contains(average(h, true), average(h, false))) mine.add(h);
out.add(new Region(o, mine, group.size(), label[0], label[1]));
}
}
out.sort(Comparator.comparingInt(Region::chunks).reversed().thenComparingDouble(r -> r.labelX()).thenComparingDouble(r -> r.labelZ()));
return out;
}
private static double average(Ring ring, boolean x) {
double sum = 0;
for (Point p : ring.points()) sum += x ? p.x() : p.z();
return sum / ring.points().size();
}
/** The middle of the chunk nearest to the area's average, so a label never lands outside a bent shape. */
static double[] labelSpot(Set<Long> group) {
double mx = 0, mz = 0;
for (long c : group) { mx += kx(c) + 0.5; mz += kz(c) + 0.5; }
mx /= group.size();
mz /= group.size();
long best = group.iterator().next();
double bestD = Double.MAX_VALUE;
for (long c : group) {
double d = Math.pow(kx(c) + 0.5 - mx, 2) + Math.pow(kz(c) + 0.5 - mz, 2);
if (d < bestD || (d == bestD && c < best)) { bestD = d; best = c; }
}
return new double[]{(kx(best) + 0.5) * 16, (kz(best) + 0.5) * 16};
}
private record Edge(int fx, int fz, int tx, int tz) {}
/** Boundary loops of one connected area. Outer loops have positive area, holes negative. */
static List<Ring> outline(Set<Long> group) {
// Directed edges with the chunk on the right-hand side (clockwise around solid land).
Map<Long, List<Edge>> from = new HashMap<>();
for (long c : group) {
int x = kx(c), z = kz(c);
if (!group.contains(key(x, z - 1))) add(from, new Edge(x, z, x + 1, z));
if (!group.contains(key(x + 1, z))) add(from, new Edge(x + 1, z, x + 1, z + 1));
if (!group.contains(key(x, z + 1))) add(from, new Edge(x + 1, z + 1, x, z + 1));
if (!group.contains(key(x - 1, z))) add(from, new Edge(x, z + 1, x, z));
}
List<Ring> rings = new ArrayList<>();
while (!from.isEmpty()) {
// Start from a stable edge so results don't depend on hash order.
long startKey = Collections.min(from.keySet());
Edge first = from.get(startKey).get(0);
List<int[]> loop = new ArrayList<>();
Edge e = first;
do {
remove(from, e);
loop.add(new int[]{e.fx(), e.fz()});
e = next(from, e);
if (e == null) break;
} while (!(e.fx() == first.fx() && e.fz() == first.fz()));
rings.add(new Ring(simplify(loop)));
}
return rings;
}
private static void add(Map<Long, List<Edge>> m, Edge e) { m.computeIfAbsent(key(e.fx(), e.fz()), k -> new ArrayList<>()).add(e); }
private static void remove(Map<Long, List<Edge>> m, Edge e) {
List<Edge> list = m.get(key(e.fx(), e.fz()));
list.remove(e);
if (list.isEmpty()) m.remove(key(e.fx(), e.fz()));
}
/** The edge leaving this edge's end. Where two loops touch at a corner, turn right to keep them apart. */
private static Edge next(Map<Long, List<Edge>> m, Edge e) {
List<Edge> options = m.get(key(e.tx(), e.tz()));
if (options == null || options.isEmpty()) return null;
if (options.size() == 1) return options.get(0);
int dx = e.tx() - e.fx(), dz = e.tz() - e.fz();
Edge best = options.get(0);
int bestScore = Integer.MIN_VALUE;
for (Edge o : options) {
int ox = o.tx() - o.fx(), oz = o.tz() - o.fz();
int cross = dx * oz - dz * ox; // >0: turns right in (x right, z down) coordinates
int score = cross > 0 ? 2 : cross == 0 ? 1 : 0;
if (score > bestScore) { bestScore = score; best = o; }
}
return best;
}
/** Drop corner points that sit in a straight line, then scale chunks to blocks. */
private static List<Point> simplify(List<int[]> loop) {
List<int[]> kept = new ArrayList<>();
int n = loop.size();
for (int i = 0; i < n; i++) {
int[] prev = loop.get((i + n - 1) % n), cur = loop.get(i), nxt = loop.get((i + 1) % n);
int cross = (cur[0] - prev[0]) * (nxt[1] - cur[1]) - (cur[1] - prev[1]) * (nxt[0] - cur[0]);
if (cross != 0) kept.add(cur);
}
List<Point> out = new ArrayList<>();
for (int[] p : kept) out.add(new Point(p[0] * 16.0, p[1] * 16.0));
return out;
}
}
@@ -0,0 +1,18 @@
package net.scopenet.core.map;
import java.util.List;
/** What the map shows, independent of any map software. Built by {@link MapModel}. */
public record MapData(List<ClaimRegion> claims, List<Pin> pins, List<OnlinePlayer> players) {
public static MapData empty() { return new MapData(List.of(), List.of(), List.of()); }
/** One connected area of a guild's land. {@code color} is 0xRRGGBB. */
public record ClaimRegion(String id, String guildId, String name, String tag, String icon, int color, String dimension,
ChunkRegions.Ring outer, List<ChunkRegions.Ring> holes, int chunks, double labelX, double labelZ, String detail) {}
/** kind: spawn, warp, home, guild_home, market or shop. {@code detail} is HTML that is already escaped. */
public record Pin(String id, String kind, String label, String dimension, double x, double y, double z, String detail) {}
public record OnlinePlayer(String uuid, String name, long level, String title, long serverLevel, String guild, String guildTag,
String guildRole, String rank, long playtimeSecs) {}
}
@@ -0,0 +1,153 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.core.Pos;
import net.scopenet.core.ShopPoints;
import net.scopenet.integration.ClaimIndex;
import java.util.*;
/** Builds {@link MapData} from what the server already knows: claims, warps, spawn, shop points, listings and players. */
public final class MapModel {
private MapModel() {}
/** Everything the map is built from. All of it must be safe to read from another thread. */
public record Sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Map<String, Pos> warps, Collection<Pos> spawns,
Map<UUID, Map<String, Pos>> homes, Map<String, NamedPos> guildHomes, Collection<ShopPoints.Shop> shops,
JsonArray market, List<MapData.OnlinePlayer> players, String panelBase, boolean showHomes,
Map<UUID, String> homeOwners) {}
public record NamedPos(String label, Pos pos) {}
public static String escape(String text) {
if (text == null) return "";
StringBuilder out = new StringBuilder(text.length() + 8);
for (char c : text.toCharArray()) {
switch (c) {
case '&' -> out.append("&amp;");
case '<' -> out.append("&lt;");
case '>' -> out.append("&gt;");
case '"' -> out.append("&quot;");
case '\'' -> out.append("&#39;");
default -> { if (c >= 32 || c == '\n') out.append(c); }
}
}
return out.toString();
}
/** A stable, pleasant colour per guild: hue from the id, fixed saturation and lightness. */
public static int guildColor(String guildId) {
int h = guildId.hashCode();
double hue = Math.floorMod(h, 360);
return hsl(hue, 0.65, 0.55);
}
static int hsl(double h, double s, double l) {
double c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c / 2;
double r, g, b;
if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; }
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
}
/** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
public static String dimensionOf(String id, String name) {
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
if (words.contains("nether")) return "minecraft:the_nether";
if (words.contains("end")) return "minecraft:the_end";
return "minecraft:overworld";
}
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
public static String iconAddress(String icon, String panelBase) {
if (icon == null || icon.isBlank()) return "";
String i = icon.trim();
if (i.startsWith("https://") || i.startsWith("http://")) return i;
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
return "";
}
public static MapData build(Sources s) {
List<MapData.ClaimRegion> claims = new ArrayList<>();
s.claims().forEach((dimension, guilds) -> {
for (ClaimIndex.GuildClaims g : guilds) {
int n = 0;
for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) {
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()),
guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail));
}
}
});
List<MapData.Pin> pins = new ArrayList<>();
int i = 0;
for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(),
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(),
home.pos().x(), home.pos().y(), home.pos().z(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
if (s.showHomes()) {
s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> {
String who = s.homeOwners().getOrDefault(owner, "a player");
pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
}));
}
String marketDetail = marketSummary(s.market());
for (ShopPoints.Shop shop : s.shops()) {
boolean market = shop.kind().equals("market");
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
Pos p = shop.pos();
pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail));
}
pins.sort(Comparator.comparing(MapData.Pin::id));
return new MapData(claims, pins, s.players());
}
/** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */
static String marketSummary(JsonArray listings) {
if (listings == null || listings.size() == 0) return "<br><em>No listings right now.</em>";
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
int shown = 0;
for (JsonElement e : listings) {
if (shown++ >= 5) break;
JsonObject l = e.getAsJsonObject();
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name");
b.append("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
.append(" &middot; ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0))
.append(" <span>by ").append(escape(guild)).append("</span></li>");
}
return b.append("</ul>").toString();
}
private static String text(JsonObject o, String key) { return o.has(key) && o.get(key).isJsonPrimitive() ? o.get(key).getAsString() : ""; }
/** The file the map page reads to describe players who are online (public facts only: never balances). */
public static JsonObject playersJson(String server, List<MapData.OnlinePlayer> players, long now) {
JsonObject root = new JsonObject();
root.addProperty("updated", now);
root.addProperty("server", server);
JsonObject byId = new JsonObject();
for (MapData.OnlinePlayer p : players) {
JsonObject o = new JsonObject();
o.addProperty("name", p.name());
o.addProperty("level", p.level());
o.addProperty("title", p.title());
o.addProperty("serverLevel", p.serverLevel());
o.addProperty("guild", p.guild());
o.addProperty("guildTag", p.guildTag());
o.addProperty("guildRole", p.guildRole());
o.addProperty("rank", p.rank());
o.addProperty("playtime", p.playtimeSecs());
byId.add(p.uuid(), o);
}
root.add("players", byId);
return root;
}
}
@@ -0,0 +1,78 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.core.*;
import net.scopenet.integration.ClaimIndex;
import java.util.*;
/**
* Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
*/
public final class MapService {
public static final long MARKET_REFRESH_MS = 60_000;
private final Env env;
private final ClaimIndex claims;
private final MapSource places;
private final PlayerCache cache;
private final String panelBase;
private final boolean showHomes;
private final String serverName;
private volatile JsonArray market = new JsonArray();
private volatile long marketAt = Long.MIN_VALUE / 2;
private volatile boolean fetching;
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
}
public String serverName() { return serverName; }
/** Server thread, about once a second. */
public void tick() {
long now = env.clock.getAsLong();
if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return;
fetching = true;
marketAt = now;
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
fetching = false;
}, e -> fetching = false);
}
/** Safe from any thread: reads only copies and concurrent collections. */
public MapData snapshot() {
List<MapData.OnlinePlayer> players = onlinePlayers();
Map<UUID, String> names = new HashMap<>();
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
Map<String, String> labels = new HashMap<>();
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
MapModel.Sources sources = new MapModel.Sources(
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
env.features.essentials() ? places.warps() : Map.of(),
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
market, players, panelBase, showHomes, names);
return MapModel.build(sources);
}
private List<MapData.OnlinePlayer> onlinePlayers() {
List<MapData.OnlinePlayer> out = new ArrayList<>();
for (CorePlayer p : env.platform.online()) {
JsonObject i = cache.info(p.uuid());
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank");
out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""),
(long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""),
PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0)));
}
return out;
}
public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); }
}
@@ -0,0 +1,17 @@
package net.scopenet.core.map;
import net.scopenet.core.Pos;
import net.scopenet.core.ShopPoints;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
public interface MapSource {
Map<String, Pos> warps();
Map<UUID, Map<String, Pos>> homes();
/** Guild homes by guild id. */
Map<String, Pos> guildHomes();
Collection<ShopPoints.Shop> shops();
}
@@ -0,0 +1,117 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.util.*;
import java.util.function.LongSupplier;
/** All callers (including asynchronous chat events) share one short lock. */
public final class Activity {
private record Player(String name, long since) {}
private final Map<UUID, Player> online = new LinkedHashMap<>();
private final Map<UUID, JsonObject> stats = new LinkedHashMap<>();
private final List<JsonObject> events = new ArrayList<>();
private final Map<String, JsonObject> actions = new LinkedHashMap<>();
private long dropped;
private final LongSupplier clock;
private static final int MAX_PLAYERS = 5000;
private static final int MAX_EVENTS = 500;
public Activity() { this(System::nanoTime); }
Activity(LongSupplier clock) { this.clock = clock; }
public synchronized void join(UUID id, String name) {
if (online.containsKey(id)) return;
if (online.size() >= MAX_PLAYERS) return;
online.put(id, new Player(name, clock.getAsLong()));
add(id, name, "joins", 1);
event(id, name, "join", null);
}
public synchronized void leave(UUID id, String name) {
Player player = online.remove(id);
if (player == null) return;
add(id, name, "playtime_secs", Math.max(0, (clock.getAsLong() - player.since()) / 1_000_000_000L));
event(id, name, "leave", null);
}
public synchronized void add(UUID id, String name, String key, long count) {
if (count <= 0 || (!stats.containsKey(id) && stats.size() >= MAX_PLAYERS)) return;
JsonObject row = stats.computeIfAbsent(id, unused -> identity(id, name));
row.addProperty(key, Math.min(1_000_000L, (row.has(key) ? row.get(key).getAsLong() : 0) + count));
}
public synchronized void event(UUID id, String name, String kind, String detail) {
if (events.size() >= 10_000) { dropped++; return; }
JsonObject event = identity(id, name);
event.addProperty("kind", kind);
if (detail != null) event.addProperty("detail", detail.substring(0, Math.min(256, detail.length())));
event.addProperty("at", System.currentTimeMillis());
events.add(event);
}
/** Repeated vanilla actions are aggregated within each reporting interval. */
public synchronized void action(UUID id, String name, String action, long count) {
String key = id + ":" + action;
if (count <= 0) return;
if (!actions.containsKey(key) && actions.size() >= 10_000) { dropped++; return; }
JsonObject row = actions.computeIfAbsent(key, unused -> {
JsonObject value = identity(id, name);
value.addProperty("kind", "action");
value.addProperty("action", action);
value.addProperty("count", 0);
value.addProperty("at", System.currentTimeMillis());
return value;
});
row.addProperty("count", row.get("count").getAsLong() + count);
}
public synchronized JsonObject drain(double tps) {
long now = clock.getAsLong();
JsonArray players = new JsonArray();
online.replaceAll((id, player) -> {
long seconds = Math.max(0, (now - player.since()) / 1_000_000_000L);
add(id, player.name(), "playtime_secs", seconds);
players.add(identity(id, player.name()));
return new Player(player.name(), player.since() + seconds * 1_000_000_000L);
});
JsonObject payload = new JsonObject();
payload.addProperty("batch_id", UUID.randomUUID().toString());
payload.addProperty("tps", Double.isFinite(tps) ? Math.max(0, Math.min(20, tps)) : 20);
payload.add("online", players);
JsonArray rows = new JsonArray();
stats.values().forEach(rows::add);
payload.add("stats", rows);
JsonArray activity = new JsonArray();
int eventCount = Math.min(events.size(), MAX_EVENTS - 1);
events.subList(0, eventCount).forEach(activity::add);
events.subList(0, eventCount).clear();
var iterator = actions.values().iterator();
while (iterator.hasNext() && activity.size() < MAX_EVENTS - 1) {
JsonObject action = iterator.next();
action.addProperty("detail", action.remove("action").getAsString() + " +" + action.remove("count").getAsLong());
activity.add(action);
iterator.remove();
}
if (dropped > 0) {
JsonObject gap = new JsonObject();
gap.addProperty("kind", "telemetry_gap");
gap.addProperty("detail", dropped + " events exceeded the local queue capacity");
activity.add(gap);
dropped = 0;
}
payload.add("events", activity);
stats.clear();
return payload;
}
public synchronized void leaveAll() {
new LinkedHashMap<>(online).forEach((id, player) -> leave(id, player.name()));
}
private static JsonObject identity(UUID id, String name) {
JsonObject object = new JsonObject();
object.addProperty("uuid", id.toString());
object.addProperty("name", name);
return object;
}
}
@@ -0,0 +1,9 @@
package net.scopenet.integration;
public record ChunkCheckResult(
boolean claimed,
boolean allowed,
String guildName,
String guildTag
) {
}
@@ -0,0 +1,112 @@
package net.scopenet.integration;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Every claimed chunk on this server, and who may build in each, held in
* memory. Block events ask this instead of the panel, so protection costs a
* couple of hash lookups and keeps working if the panel is briefly away.
* The panel is polled for a new copy only when its revision changes.
*/
public final class ClaimIndex {
private record Guild(String id, String name, String tag, String icon) {}
private record Snapshot(String revision, Map<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {}
private static final ChunkCheckResult WILDERNESS = new ChunkCheckResult(false, true, null, null);
private volatile Snapshot snapshot;
static long key(int chunkX, int chunkZ) {
return ((long) chunkX << 32) | (chunkZ & 0xffffffffL);
}
/** False until the first copy arrives from the panel. */
public boolean loaded() { return snapshot != null; }
public String revision() {
Snapshot s = snapshot;
return s == null ? null : s.revision();
}
public int claimCount() {
Snapshot s = snapshot;
return s == null ? 0 : s.total();
}
/** Is this chunk claimed by any guild? Used for environmental changes (fire, pistons, explosions). */
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
Snapshot s = snapshot;
if (s == null) return false;
Map<Long, Guild> dim = s.claims().get(dimension);
return dim != null && dim.containsKey(key(chunkX, chunkZ));
}
/** May {@code player} build here? Unclaimed land always answers yes. */
public ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player) {
Snapshot s = snapshot;
if (s == null) return WILDERNESS;
Map<Long, Guild> dim = s.claims().get(dimension);
Guild guild = dim == null ? null : dim.get(key(chunkX, chunkZ));
if (guild == null) return WILDERNESS;
Set<UUID> members = s.members().get(guild.id());
return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag());
}
/** One guild's claimed chunks in one dimension, for drawing on a map. */
public record GuildClaims(String id, String name, String tag, String icon, Set<Long> chunks) {}
public static int chunkX(long key) { return (int) (key >> 32); }
public static int chunkZ(long key) { return (int) key; }
/** Every claim grouped by dimension and guild (a copy; safe to use from any thread). */
public Map<String, java.util.List<GuildClaims>> byDimension() {
Snapshot s = snapshot;
Map<String, java.util.List<GuildClaims>> out = new HashMap<>();
if (s == null) return out;
s.claims().forEach((dimension, chunks) -> {
Map<String, GuildClaims> perGuild = new java.util.LinkedHashMap<>();
chunks.forEach((key, guild) -> perGuild.computeIfAbsent(guild.id(),
id -> new GuildClaims(guild.id(), guild.name(), guild.tag(), guild.icon(), new HashSet<>())).chunks().add(key));
out.put(dimension, new java.util.ArrayList<>(perGuild.values()));
});
return out;
}
/** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */
public boolean apply(JsonObject response) {
if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false;
String revision = response.has("revision") ? response.get("revision").getAsString() : "";
JsonArray guildList = response.getAsJsonArray("guilds");
Guild[] guilds = new Guild[guildList.size()];
for (int i = 0; i < guilds.length; i++) {
JsonObject g = guildList.get(i).getAsJsonObject();
guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString(),
g.has("icon_url") && g.get("icon_url").isJsonPrimitive() ? g.get("icon_url").getAsString() : "");
}
Map<String, Map<Long, Guild>> claims = new HashMap<>();
JsonArray claimList = response.getAsJsonArray("claims");
for (JsonElement element : claimList) {
JsonArray c = element.getAsJsonArray();
int guild = c.get(3).getAsInt();
if (guild < 0 || guild >= guilds.length) continue;
claims.computeIfAbsent(c.get(0).getAsString(), d -> new HashMap<>()).put(key(c.get(1).getAsInt(), c.get(2).getAsInt()), guilds[guild]);
}
Map<String, Set<UUID>> members = new HashMap<>();
for (Map.Entry<String, JsonElement> entry : response.getAsJsonObject("members").entrySet()) {
Set<UUID> set = new HashSet<>();
for (JsonElement uuid : entry.getValue().getAsJsonArray()) {
try { set.add(UUID.fromString(uuid.getAsString())); } catch (IllegalArgumentException ignored) { /* skip malformed */ }
}
members.put(entry.getKey(), set);
}
snapshot = new Snapshot(revision, claims, members, claimList.size());
return true;
}
}
@@ -0,0 +1,199 @@
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 sending player positions for the in-app BlueMap territory view. */
public void enableLiveMap() {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client);
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);
}
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,70 @@
package net.scopenet.integration;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private final PanelClient client;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "scopenet-positions");
thread.setDaemon(true);
return thread;
});
private volatile List<Player> players = List.of();
private volatile boolean changed;
private volatile boolean closed;
private long lastSent;
public LiveMapSync(PanelClient client) { this.client = client; }
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
changed = true;
}
}
private void sendSafely() {
if (closed) return;
long now = System.nanoTime();
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject entry = new JsonObject();
entry.addProperty("uuid", p.uuid());
entry.addProperty("name", p.name());
entry.addProperty("dimension", p.dimension());
entry.addProperty("x", p.x());
entry.addProperty("y", p.y());
entry.addProperty("z", p.z());
entry.addProperty("yaw", p.yaw());
entry.addProperty("pitch", p.pitch());
list.add(entry);
}
JsonObject body = new JsonObject();
body.add("players", list);
try {
client.post("livemap/players", body);
changed = false;
lastSent = now;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// Keep the latest snapshot and retry on the next interval.
}
}
@Override public void close() {
closed = true;
worker.shutdownNow();
}
}
@@ -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();
}
}
Loaded 100 of 351 files, more files were not shown because too many files have changed in this diff. Show more