Author SHA1 Message Date
Claude c0bf39071a Fix Emails page structuredClone error on load
CI / Launcher (Windows build check) (push) Failing after 11s
CI / Rust (fmt, clippy, tests) (push) Failing after 3m20s
CI / Web UIs (type-check + build) (launcher) (push) Successful in 2m55s
CI / Web UIs (type-check + build) (panel/web) (push) Successful in 2m59s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Failing after 9m11s
CI / Docker image builds (push) Canceled after 9m53s
Clone from the raw API response instead of the $state proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvKPBTwMK2SLDNUp5SxR7A
2026-10-01 02:30:00 +00:00
SCOPEDD deabea60ca Merge pull request #4 from scopeddlol/codex/complete-private-auth-integrations
SCOPENET Map, rewards, configurable limits, Discord studio, emails and UI overflow fixes
2026-09-30 22:00:37 -04:00
Claude 7eea617225 Panel: mobile overflow fixes
CI / Launcher (Windows build check) (push) Failing after 13s
CI / Rust (fmt, clippy, tests) (push) Failing after 2m51s
CI / Web UIs (type-check + build) (launcher) (push) Successful in 2m37s
CI / Web UIs (type-check + build) (panel/web) (push) Successful in 2m33s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Successful in 8m31s
CI / Docker image builds (push) Canceled after 34m49s
2026-10-01 01:56:54 +00:00
Claude aae0a5a5ff Public landing page live map; docs for Discord, live embeds, email 2026-10-01 01:52:54 +00:00
Claude cd985a1a7b Emails: templates, branded HTML, audiences, opt-out, log; admin UI 2026-10-01 01:49:02 +00:00
Claude 2cc009d66f Admin Discord studio: editor, live preview, live embeds 2026-10-01 01:44:59 +00:00
Claude f9b0dcedff Discord: customizable announcements and live embeds (status, guilds, leaderboard, baltop) backend 2026-10-01 01:43:00 +00:00
Claude 6fd78f32ea UI: text wraps inside its box everywhere (global wrapping, shrinkable flex items, wrapping buttons, unknown icon fallback) 2026-10-01 01:37:42 +00:00
Claude 6c58042a32 Map: individual blocks at close zoom (texture, bevel, chunk lines, hover outline) 2026-10-01 01:18:13 +00:00
Claude 8e1f04c867 README links 2026-10-01 01:17:02 +00:00
Claude 74ca07fe93 Docs and examples for the SCOPENET Map, rewards and limits; BlueMap/Vantage docs removed 2026-10-01 01:15:52 +00:00
Claude 280f9eaf1b SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed 2026-10-01 01:15:05 +00:00
Claude 19d111eeff Admin rules: starting balance, guild claim/member limits, market listing limit; panel UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 01:06:51 +00:00
Claude c859628e93 Configurable essentials: home tiers by permission, cooldowns, warp cap, RTP radii; rewards config 2026-10-01 01:01:54 +00:00
Claude 0dc948caa4 Rewards: delivery on Paper/Fabric, admin bundle editor, launcher chips
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:59:08 +00:00
Claude 209ade0119 Rewards beyond titles: bundles on quests, achievements and rank milestones; panel-side money/chunks/badges; delivery queue
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:55:18 +00:00
Claude c37cde5cc8 Player names open profiles across the launcher and admin panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:49:13 +00:00
Claude f428bd210f Rank title renames now reach every player (server ranks recomputed too)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:47:10 +00:00
Claude 5346b1f560 Test tidy 2026-10-01 00:43:34 +00:00
Claude d98cc61ee6 Map: game servers send tiles, players and overlay; BlueMap and LiveMapSync removed from the Java side
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:43:11 +00:00
Claude ead4a706c7 Map: block colours from real block textures; palette-limited PNG tiles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:34:21 +00:00
Claude 30b1d92437 Merge remote work into the SCOPENET Map branch (livemap/Vantage stay removed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:32:34 +00:00
Claude dbe02dc9bf Map: Java renderer core (NBT, Anvil, colours, tiles, palette PNG, zoom pyramid, uploader)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-10-01 00:28:01 +00:00
Claude 9102e2fc52 Panel: SCOPENET Map storage and endpoints; Vantage proxy removed 2026-10-01 00:20:04 +00:00
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
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
197 changed files with 11759 additions and 3569 deletions

No files matched your search

-2
View File
@@ -11,6 +11,4 @@ PUBLIC_URL=
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
SCOPENET_TRUSTED_PROXIES=
# Live Map: optional static Vantage Linux release to bundle, and memory for the panel.
VANTAGE_URL=
PANEL_MEMORY=256M
Generated
+4 -4
View File
@@ -3999,7 +3999,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scopenet-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4021,7 +4021,7 @@ dependencies = [
[[package]]
name = "scopenet-launcher"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4047,7 +4047,7 @@ dependencies = [
[[package]]
name = "scopenet-panel"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"argon2",
@@ -4083,7 +4083,7 @@ dependencies = [
[[package]]
name = "scopenet-shared"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"md-5",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2021"
repository = "https://github.com/scopeddlol/SCOPENET-MC"
+2 -17
View File
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src/panel/web
COPY panel/web/package.json panel/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
# The map viewer is shared with the launcher (imported as @scopenet/map).
COPY shared/ /src/shared/
COPY panel/web/ ./
RUN npm run build
@@ -36,20 +38,6 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry,sharin
# Data dir owned by the non-root runtime user.
RUN mkdir -p /out/data && chown 65532:65532 /out/data
# ---- 2b. Vantage generator (optional) ----------------------------------------
# The live map renders with Vantage. Point VANTAGE_URL at a static Linux
# release archive (tar.gz or zip) for the target arch to bake it into the image;
# leave it empty to mount your own binary at /opt/vantage/vantage instead.
FROM --platform=$BUILDPLATFORM alpine:3 AS vantage
ARG VANTAGE_URL=""
RUN apk add --no-cache curl tar unzip && mkdir -p /out/vantage /out/assets \
&& if [ -n "$VANTAGE_URL" ]; then \
curl -fsSL "$VANTAGE_URL" -o /tmp/vantage.pkg \
&& (tar -xzf /tmp/vantage.pkg -C /out/vantage 2>/dev/null || unzip -q /tmp/vantage.pkg -d /out/vantage) \
&& find /out/vantage -type f -name vantage -exec chmod 755 {} + \
&& (f=$(find /out/vantage -type f -name vantage | head -n1) && [ -n "$f" ] && [ "$f" != /out/vantage/vantage ] && mv "$f" /out/vantage/vantage || true); \
fi
# ---- 3. Runtime --------------------------------------------------------------
FROM scratch
LABEL org.opencontainers.image.title="SCOPENET Panel" \
@@ -58,12 +46,9 @@ LABEL org.opencontainers.image.title="SCOPENET Panel" \
COPY --from=build /scopenet-panel /scopenet-panel
COPY --from=web /src/panel/web/dist /web
COPY --from=build --chown=65532:65532 /out/data /data
COPY --from=vantage /out/vantage /opt/vantage
ENV SCOPENET_BIND=0.0.0.0:8080 \
SCOPENET_DATA_DIR=/data \
SCOPENET_WEB_DIR=/web \
SCOPENET_VANTAGE_BIN=/opt/vantage/vantage \
SCOPENET_VANTAGE_ASSETS=/data/livemap/assets \
RUST_LOG=info
USER 65532:65532
VOLUME ["/data"]
+3
View File
@@ -99,6 +99,9 @@ docker-compose.yml Panel deployment
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
- [Development](docs/development.md) — running everything locally, tests
- [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups
-8
View File
@@ -10,9 +10,6 @@ services:
image: scopenet-panel:local
build:
context: .
args:
# Optional: URL of a static Vantage Linux release to bundle.
VANTAGE_URL: ${VANTAGE_URL:-}
container_name: scopenet-panel
restart: unless-stopped
ports:
@@ -29,10 +26,6 @@ services:
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
PUBLIC_URL: ${PUBLIC_URL:-}
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
# Live Map: Vantage generator + Minecraft client assets (see docs/live-map.md).
SCOPENET_VANTAGE_BIN: ${SCOPENET_VANTAGE_BIN:-/opt/vantage/vantage}
SCOPENET_VANTAGE_ASSETS: ${SCOPENET_VANTAGE_ASSETS:-/data/livemap/assets}
SCOPENET_VANTAGE_ARGS: ${SCOPENET_VANTAGE_ARGS:-}
RUST_LOG: ${RUST_LOG:-info}
volumes:
- panel-data:/data
@@ -40,7 +33,6 @@ services:
deploy:
resources:
limits:
# Raise this if you enable Live Map: Vantage bakes tiles in memory.
memory: ${PANEL_MEMORY:-256M}
volumes:
+2 -5
View File
@@ -23,11 +23,8 @@ The panel listens on `PANEL_PORT` (default `8080`). Docker checks
`docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
blank on first start, the generated password appears in the panel logs.
To show a Vantage world map in the launcher, deploy Vantage next to each game
server and configure its manifest URL in the panel. See
[Live map setup](live-map.md). Vantage needs access to the game world's saved
files, so it runs alongside the game server rather than inside the panel
container.
Servers draw their own world map and send it to the panel; nothing extra to
install or host. See [The SCOPENET Map](map.md).
The named `panel-data` volume holds the database, uploads, hosted launcher
downloads, and the generated JWT signing key. Preserve that volume when
+39
View File
@@ -0,0 +1,39 @@
# Discord messages, live embeds and emails
## Announcements (Admin panel → Discord → Announcements)
Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it:
- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields.
- The sender's name and picture.
- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot.
- Player names are cleaned so they can't ping anyone or break the formatting.
Switch each message on or off, and press **Save & send a sample** to see it in your channel.
## Live embeds (Discord → Live embeds)
Four boards that the panel posts once and then **edits in place**, so the channel never fills up:
| Embed | Shows | Rows are set by |
|---|---|---|
| **Server status** | Every server: online, players, TPS, version | the line template |
| **Active guilds** | Top guilds with level, members, land and leader | rows shown |
| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown |
| **Richest players** | Top balances of one server economy | economy of, rows shown |
Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it.
## Emails (Admin panel → Emails)
Set up the sender and Resend key under **Settings → Connections** first. Then:
1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`.
2. Check the live **preview**, and **send a test** to yourself.
3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons.
Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account.
## Live map on the landing page
In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again.
-73
View File
@@ -1,73 +0,0 @@
# Live Map (central Vantage)
> **Setting it up?** Follow the step-by-step guide: [vantage-setup.md](vantage-setup.md). This page is the overview.
The panel hosts a [Vantage](https://github.com/thoughts-on-things/vantage-mc) 3D map
**for every game server**, so nobody has to run Vantage next to Minecraft.
```
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
```
1. **Collect** – the SCOPENET Paper plugin / Fabric / Forge mod reads the world's
region files (only chunks newer than what the panel already has) and sends them to
the panel over HTTPS. Player positions stream over a WebSocket (HTTPS fallback).
2. **Store & render** – the panel keeps a region-file mirror per server under
`<data>/livemap/<server id>/world` and runs a `vantage server` sidecar over it on
demand (stopped again after 15 idle minutes). Sidecars listen on loopback only.
3. **Serve** – `GET /api/livemap/<server>/<dimension>/v1/worlds/default/…` proxies the
sidecar to signed-in launcher/admin sessions. Player markers come straight from
memory. One map per server and dimension (`overworld`, `the_nether`, `the_end`).
4. **View** – the launcher checks `GET /api/v1/servers/<id>/livemap`; when Live Map is
on for a server linked to the instance, the instance page shows a **Live Map**
button that opens the map inside the launcher (and the Guilds territory view uses
it too). Admins see the same map on *Servers → your server*.
Chunk data only reaches the panel when the game server has written it, so the map
follows autosaves (and `/save-all`) rather than every block change.
## Turn it on
1. **Servers → your server → Settings → Live Map** (on by default for new servers).
2. Update the plugin/mod. `livemap.enabled: true` (Paper `config.yml`) or
`livemap.enabled=true` (`config/scopenet.properties`) is the default; set it to
`false` to keep a server off the map locally.
3. The server page shows a checklist: generator, assets, world data, player positions.
## Panel requirements
Rendering needs two things on the machine running the panel:
| Setting | Meaning |
| --- | --- |
| `SCOPENET_VANTAGE_BIN` | The `vantage` executable (default: `vantage` on `PATH`, then `/opt/vantage/vantage`). |
| `SCOPENET_VANTAGE_ASSETS` | Minecraft client assets directory passed as `--assets` (default `<data>/livemap/assets`). |
| `SCOPENET_VANTAGE_ARGS` | Optional extra `vantage server` flags, e.g. `--radius 2048 --memory 1024`. |
With Docker, either mount your binary at `/opt/vantage/vantage` and the assets at
`/data/livemap/assets`, or bake a static Linux release in with
`VANTAGE_URL=<release archive> docker compose build`. Until both exist the panel still
stores what servers send and launchers show "the map is being prepared".
Give the container more than the default 256 MB if Live Map is on (`PANEL_MEMORY`).
## Security
* Game servers authenticate with their existing `sn_…` token; the endpoints refuse
servers whose Live Map switch is off.
* Viewers must be signed in; the launcher attaches its session token natively, so it
never reaches page scripts. Anyone with an account can view a server's map — use
the server's access rules and leave Live Map off for private worlds.
* The sidecar gets a random per-process bearer token and never leaves loopback.
## Ingest API (for reference)
All under `/api/server/v1/livemap/` with `Authorization: Bearer <server token>`:
`GET config`, `GET manifest?dim=`, `POST chunks?dim=` (binary records of
`i32 x, i32 z, u32 timestamp, u8 compression, u32 length, bytes`), `POST level`
(`level.dat`), `POST players`, `GET ws` (WebSocket: JSON `players` frames).
## External Vantage (advanced)
Under *Use an external Vantage map instead* you can still point a server at a
`manifest.json` / `world.json` you host yourself. It is only used when Live Map is off.
+48
View File
@@ -0,0 +1,48 @@
# The SCOPENET Map
Every server draws its own world map. There is nothing to install, host or configure besides the plugin or mod you already run: the game server renders its world to small tiles in the background, sends only what changed to the panel, and the launcher and admin panel show them in a fast native map.
What's on it:
- **Terrain** in the look of a Minecraft map, coloured from the real block textures (modded blocks get a matching colour). The Nether is drawn below its ceiling, so you see caves and lava lakes, not bedrock.
- **Guild land** as coloured outlines with the guild's icon and name, holes and all.
- **Spawn, warps, guild homes, markets and shop points** as pins you can click.
- **Players online**, with their head and facing direction. Click one to open the player panel (profile, teleport request, message in game, invite to your guild).
- **Dimensions**: Overworld, Nether and End tabs appear as soon as the server has drawn them.
## Turn it on
It is on by default for every server. To change it:
1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch.
2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read.
The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority.
## In the launcher
Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles.
## In the admin panel
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
## On your public landing page
Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page).
## How it works
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
- Tiles are palette PNGs, so a typical tile is a few kilobytes. Only tiles that changed are uploaded, and a region is redrawn at most every 90 seconds while players are building.
- The panel stores tiles under its data folder (`map/<server id>/…`) and serves them to signed-in players only, with a short-lived key on every address and ETag caching.
- Player positions go up about every two seconds; claims and pins whenever they change.
- A local cache lives next to the plugin (`plugins/SCOPENET/map`) or mod (`config/scopenet/map`) so a restart doesn't redraw anything.
Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the panel. Servers using region files compressed with LZ4 (an opt-in setting in recent Minecraft versions) are not drawn; leave `region-file-compression` on its default.
## Troubleshooting
- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing.
- **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18).
- **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour.
+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 draws on the server's SCOPENET Map tiles (see [map.md](map.md)). It centers on the player's last position while they are in game. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
## Warps and item sharing on Paper
Players use `/reqwarp <name>` at the requested location. An operator or player with `scopenet.command.warp.manage` uses `/warprequests`, then `/warpapprove <name>` or `/warpdeny <name>`. Admins can create and remove warps directly with `/setwarp <name>` and `/delwarp <name>`. Pending requests are stored in `plugins/SCOPENET/warp_requests.yml`; approved warps are stored in `warps.yml`.
`/hand` sends the held item as a clickable chat entry. Other players can inspect a read-only snapshot for five minutes. The launcher **Command Guide** lists these commands and their permission nodes.
+70
View File
@@ -0,0 +1,70 @@
# Rewards, limits and permission nodes
## Rewards beyond XP
Quests, achievements and rank milestones (level rewards) can hand out more than XP and a title. In the admin panel, open any quest, achievement or milestone and use **Extra rewards**:
| Reward | What happens | Where it happens |
|---|---|---|
| **Money** | Credits the balance, once per economy group (or one chosen server) | Panel, instantly |
| **Claim chunks** | Raises the land limit of the player's guild while they are a member | Panel, instantly |
| **Badge** | Shows a badge on their profile | Panel, instantly |
| **Item** | Puts the item in their inventory (anything that doesn't fit drops at their feet). Vanilla ids such as `diamond` or `modid:item` | Game server, when they are online |
| **Permission** | Grants a permission node, permanently or for a number of days | Game server (LuckPerms by default) |
| **Group** | Adds them to a permission group | Game server (LuckPerms by default) |
| **Message** | Sends a chat message | Game server |
| **Command** | Runs a console command with `{player}` and `{uuid}` filled in | Game server, only if the server allows it |
Players see short summaries ("$250.00", "Diamond x3", "+4 claim chunks") next to each quest and achievement in the launcher. Commands are shown only as "A special reward".
Items, permissions, groups and messages are queued in the panel and handed to the game server once the player is online there. Anything that fails (an unknown item, LuckPerms missing) is retried a few times and then kept in the delivery log with the reason. A reward earned on one server can be delivered by whichever server the player is on, unless you pin it to a server.
### Server settings
Paper `config.yml` / Fabric `scopenet.properties`:
```yaml
rewards:
enabled: true
allow-commands: false # let the panel run its own console commands. Leave off unless every panel admin is trusted.
permission-command: "lp user {player} permission set {node} true"
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
group-command: "lp user {player} parent add {group}"
```
Using another permissions plugin? Change the command templates. Placeholders: `{player}`, `{uuid}`, `{node}`, `{value}`, `{duration}`, `{group}`.
## What you can configure
### Per server (plugin/mod config)
| Setting | Meaning |
|---|---|
| `essentials.max-homes` | Homes every player can set |
| `essentials.home-name-max-length` | Longest home name |
| `essentials.max-warps` | Most warps the server may have (0 = no limit) |
| `essentials.tpa-timeout-seconds` | How long a `/tpa` request stays open |
| `essentials.rtp-radius`, `rtp-min-radius`, `rtp-attempts` | Where `/rtp` sends players, and how hard it tries |
| `essentials.cooldowns.<command>` | Seconds between uses of `spawn`, `home`, `back`, `tpa`, `rtp`, `warp` |
| `map.enabled`, `map.show-homes` | The SCOPENET Map and whether homes appear on it |
| `economy.currency-symbol` | The symbol shown next to money |
| `rewards.*` | See above |
| Feature switches | `leveling`, `quests`, `achievements`, `guilds`, `social`, `essentials`, `economy` |
### Network-wide (admin panel → Progression → Economy & guild rules)
Starting balance, chunks a new guild may claim, extra claims per member and per guild level, guild member limit, and market listings per player. These apply on every server and take effect immediately.
## Permission nodes that change limits
Give these with LuckPerms (or any permissions plugin) to ranks:
| Node | Effect |
|---|---|
| `scopenet.homes.<number>` | Homes limit for the player, e.g. `scopenet.homes.10`. The highest one a player has wins and never lowers the base `max-homes` |
| `scopenet.homes.unlimited` | No home limit |
| `scopenet.cooldown.bypass` | Skip every command cooldown |
Operators and anyone with `*` get the top tier on Paper. On Fabric these nodes are off for everyone until a permissions system (such as the LuckPerms mod) grants them; operators still skip cooldowns.
Claim chunks are a guild limit, so they are changed with the network rules above or with **Claim chunks** rewards, not with a permission node.
-254
View File
@@ -1,254 +0,0 @@
# Setting up the Vantage 3D Live Map
This is the complete, step-by-step guide. For a short overview of how the pieces fit, see [live-map.md](live-map.md).
**What you end up with:** every SCOPENET-connected server gets a 3D map inside the admin panel and a **Live Map** button in the launcher. You install Vantage **once, on the panel machine**. Nothing runs next to your Minecraft servers except the SCOPENET plugin or mod you already use.
```
Minecraft server ──(SCOPENET plugin/mod)──► Panel ──runs──► vantage server ──► Admin page + Launcher button
region files + players HTTPS / WebSocket mirror of the world (loopback only)
```
> **Two things you must provide on the panel machine:** the `vantage` program, and the Minecraft **client assets** (textures and models) that Vantage needs to draw blocks. The panel will not draw a map until both exist. Everything else works out of the box.
---
## 0. Before you start
| You need | Notes |
| --- | --- |
| A running SCOPENET panel | With a **Public address** set in Settings, served over HTTPS. |
| A game server running the SCOPENET plugin/mod | Paper plugin, or the Fabric/Forge mod. The server must be online with `online-mode=true` (the same requirement as SCOPENET itself). |
| RAM | Allow **1 GB or more** for the panel when Live Map is on (the default 256 MB is too small). See [sizing](#7-sizing-and-performance). |
| Disk | Roughly the size of the world's region files, plus the rendered tile cache (often the same again). |
| A copy of Minecraft Java Edition | Needed **once**, to extract assets (step 2). It can be on any computer. |
Vantage is an MIT-licensed third-party project: <https://github.com/thoughts-on-things/vantage-mc> (product site <https://vantage.beacon-mc.io>). Its own server documentation is the authority on its flags.
---
## 1. Get the `vantage` program
Download the latest **CLI release** for the panel machine's operating system and CPU from the Vantage releases page: <https://github.com/thoughts-on-things/vantage-mc/releases/latest>.
* **Docker panel image:** the image has no shell and no libraries (it is built `FROM scratch`), so it needs a **statically linked Linux build** of `vantage` for the right architecture (x86_64 or arm64). If the release you pick is not static, it will fail to start inside the container with a "not found" error. In that case run the panel on the host, or use a static build.
* **Bare-metal panel:** any build that runs on your OS works.
Put the file somewhere permanent and make it executable (`chmod 755 vantage`). Check it runs:
```sh
./vantage --help
```
---
## 2. Get the Minecraft client assets
Vantage draws blocks from the textures and models inside Minecraft's client `.jar`. You extract them once with Vantage's own command:
```sh
vantage extract
```
It automatically finds an installed Minecraft client jar (the launcher's `versions/<version>/<version>.jar`) and writes the assets out. Run `vantage extract --help` for the flags (for example to choose the jar or the output folder).
**Which version?** Use a client jar for the same Minecraft version your servers run, or a newer one. Newer is safer if the world contains newer blocks.
**Running extract on a different computer is fine.** Do it on your own PC (which has Minecraft installed), then copy the resulting folder to the panel machine. You do not need Minecraft on the server.
**Where the panel looks:** it passes the folder in `SCOPENET_VANTAGE_ASSETS` (default `<data dir>/livemap/assets`) to `vantage server --assets`. Vantage's own examples point `--assets` at a folder ending in `assets/minecraft`. So **copy the extracted folder to the panel, then point the setting at the folder Vantage expects**. If unsure, test it directly (this command should start and print that it is listening, then stop it with Ctrl+C):
```sh
vantage server /path/to/any/world --assets /path/to/your/assets/folder --out /tmp/vantage-test
```
If Vantage complains about missing assets, try the parent or the `minecraft` subfolder and use whichever works.
> **Licence note:** the assets are Mojang's. Extract them from a client you own, and do not redistribute them.
---
## 3. Install them on the panel
Pick **one** of the two layouts.
### Option A: Docker (recommended)
The provided `docker-compose.yml` already defines the three settings. Mount your files into the data volume or bake Vantage into the image.
**A1. Mount the files** (simplest; no rebuild). Put the binary and assets on the Docker host, then add volumes to the `panel` service in `docker-compose.yml`:
```yaml
volumes:
- panel-data:/data
- ./vantage/vantage:/opt/vantage/vantage:ro # the static binary from step 1
- ./vantage/assets:/data/livemap/assets:ro # the folder from step 2
```
`SCOPENET_VANTAGE_BIN` (`/opt/vantage/vantage`) and `SCOPENET_VANTAGE_ASSETS` (`/data/livemap/assets`) already default to those paths.
**A2. Bake the binary in** (assets still come from a mount):
```sh
VANTAGE_URL="https://…/vantage-x86_64-unknown-linux-musl.tar.gz" docker compose build
```
`VANTAGE_URL` must be a direct link to a `.tar.gz` or `.zip` containing the `vantage` file for the **build machine's target architecture**.
**Raise the memory limit** in `.env` (or the compose file):
```env
PANEL_MEMORY=1536M
```
Then recreate the container:
```sh
docker compose up -d --build
```
### Option B: Panel running directly on the host
```sh
export SCOPENET_VANTAGE_BIN=/opt/vantage/vantage
export SCOPENET_VANTAGE_ASSETS=/var/lib/scopenet/livemap/assets
# Optional extra flags for `vantage server`:
export SCOPENET_VANTAGE_ARGS="--memory 1024 --threads 4"
```
If you leave the two variables unset, the panel looks for `vantage` on `PATH` and then `/opt/vantage/vantage`, and for assets in `<data dir>/livemap/assets`. With systemd, put these in the unit's `Environment=` lines.
---
## 4. Turn the map on for a server
1. In the panel: **Servers → your server → Settings → Live Map** (on by default for new servers). Save.
2. On the game server, make sure the plugin/mod has Live Map enabled. It is on by default:
* **Paper** `plugins/SCOPENET/config.yml`: `livemap.enabled: true`
* **Fabric/Forge** `config/scopenet.properties`: `livemap.enabled=true`
3. **Restart the game server** (or reload the SCOPENET config) so it starts sending data.
The plugin reads the world's saved region files, sends only chunks newer than what the panel already has, and streams player positions every second.
**World location:** the plugin reads the main world's folder (the first world Paper loads, normally `world`), and the Fabric/Forge mod reads `level-name` from `server.properties`. The Nether and End are found either inside that folder (`DIM-1`, `DIM1`) or in Paper's sibling folders (`world_nether`, `world_the_end`).
---
## 5. Check that it works
Open **Servers → your server**. The Live Map card shows four checks:
| Check | Green when | If it is red |
| --- | --- | --- |
| **Vantage generator** | The panel found the `vantage` file. | Fix `SCOPENET_VANTAGE_BIN` / the mount (step 3). |
| **Minecraft assets** | The assets folder exists. | Fix `SCOPENET_VANTAGE_ASSETS` (step 2–3). |
| **World data** | Region files have arrived. | Wait for the server to upload. Big worlds take a while the first time. |
| **Player positions** | Players are online and the server is sending them. | Have someone join; positions only flow while the server runs the plugin/mod. |
When all are green the map appears under **Overworld / Nether / End** tabs. In the launcher, the instance linked to that server shows a **Live Map** button. (The instance must be linked to the server in the panel, under the server's **Launcher instance** setting.)
**How fresh is it?** Chunk data only reaches the panel once the game server has *written it to disk*. The map therefore follows the server's autosaves (every few minutes by default). To refresh right now, run `/save-all` on the game server. Player positions are real-time.
---
## 6. How it behaves (good to know)
* **Sidecars start on demand.** The panel starts one `vantage server` per server and dimension when someone opens a map, and **stops it after 15 idle minutes**. The first open after idle takes a few seconds.
* **Loopback only.** The sidecars listen on `127.0.0.1` with a random per-process token. They are never exposed. All map traffic goes through the panel and needs a signed-in session.
* **The world mirror** lives in `<data>/livemap/<server id>/world`, and rendered tiles in a cache beside it. **Servers → Live Map → Reset map data** deletes both; the game server then uploads everything again.
* **Pre-baking.** Vantage renders tiles in the background around spawn and players, so the map gets smoother after the first visit.
---
## 7. Sizing and performance
Put extra `vantage server` flags in `SCOPENET_VANTAGE_ARGS` (space separated). The useful ones:
| Flag | What it does |
| --- | --- |
| `--memory <MB>` | Memory budget for rendering. Start at `1024`. |
| `--threads <n>` | Maximum parallel tile renders. Give at least `2`. |
| `--radius <blocks>` | Limit how far from spawn the map extends. Saves CPU and disk on huge worlds. |
| `--prebake off` | Only render what people look at. Lower CPU, slower first views. |
| `--lod off` | Skip the zoomed-out overview. Smaller cache, but the map is only visible where tiles are loaded. |
| `--scan-interval <s>` | How often it checks for changed regions (default `5`). |
| `--caves`, `--light`, `--biome-blend` | Render quality options. See Vantage's docs. |
Rules of thumb:
* **1 server, modest world:** 1 GB panel memory, `--threads 2`.
* **Several servers:** each open map dimension is its own process, and they stop after 15 idle minutes. Budget memory for the number of maps opened at once, not the number of servers.
* **Huge worlds:** set `--radius`, or `--prebake off`, and be patient on the first upload.
* **Disk:** watch `<data>/livemap`. **Reset map data** frees it.
---
## 8. Reverse proxy and HTTPS
The map is served from the panel's own address, so no new ports are needed. If the panel sits behind nginx, Caddy, Traefik or Cloudflare:
* Forward **WebSocket upgrades** to `/api/server/v1/livemap/ws`. The plugin falls back to plain HTTPS if WebSockets are blocked, but positions update faster with them.
* Allow **large request bodies** for chunk uploads (`client_max_body_size 64m;` in nginx).
* Do not cache `/api/livemap/` responses.
* Keep the panel's **Public address** set to the HTTPS URL players use.
---
## 9. Privacy and access
* Anyone with a **signed-in account** can open a server's map. To keep a world private, leave Live Map **off** for that server, or restrict the server with its access rules.
* The panel feeds Vantage **only the players who are online right now** (positions from the server plugin/mod). It does **not** expose the last-known positions of everyone who has ever logged in.
* Game servers authenticate with their normal `sn_…` token; servers with Live Map off are refused.
---
## 10. Troubleshooting
| You see | Cause and fix |
| --- | --- |
| Generator "Not installed on the panel" | The panel can't find `vantage`. Check `SCOPENET_VANTAGE_BIN`, the volume mount, and that the file is executable. In Docker, check the file is a **static** Linux build for the right CPU. |
| "Minecraft client assets for Vantage aren't configured" | The assets folder is missing or the path is wrong. Re-check step 2–3 and that the folder is readable by the container user (uid 65532). |
| "Waiting for the game server to upload this world" | The panel has no `level.dat` or region files for that dimension yet. Check the plugin/mod is connected (server page shows Online), Live Map is on in **both** the panel and the plugin config, and wait for an autosave or run `/save-all`. |
| Launcher shows "the map is being prepared" | Same as above, or the generator/assets check is red. |
| "couldn't start the Vantage generator" | The file isn't executable, is for another CPU/OS, or (in Docker) isn't static. Try running it by hand. |
| Map loads but is blank or shows pink/black blocks | Wrong or incomplete assets. Re-extract from a client jar of the right version. |
| Map is out of date | Chunks appear only after the server saves. Run `/save-all`, or lower the autosave interval in the server settings. |
| Some dimensions are greyed out | That dimension has no region data yet. Visit it in-game and save. |
| Everything is slow | Raise `PANEL_MEMORY` and `--threads`, set `--radius`, or use `--prebake off`. |
| No players on the map | Positions only arrive while the plugin/mod is running and someone is online. Confirm the "Player positions" check. |
| Panel logs: generator stopped | Normal after 15 idle minutes. It restarts on the next open. |
Still stuck? Open the server page, read **"Generator: …"** under the checklist (it shows the last error from Vantage), and check the panel logs with `RUST_LOG=debug`.
---
## 11. Reference
**Panel settings (environment variables)**
| Variable | Default | Meaning |
| --- | --- | --- |
| `SCOPENET_VANTAGE_BIN` | `vantage` on `PATH`, then `/opt/vantage/vantage` | The Vantage executable. |
| `SCOPENET_VANTAGE_ASSETS` | `<data dir>/livemap/assets` | Extracted Minecraft client assets. |
| `SCOPENET_VANTAGE_ARGS` | none | Extra flags appended to `vantage server`. |
| `PANEL_MEMORY` (Docker) | `256M` | Container memory limit. Use `1G` or more for Live Map. |
| `VANTAGE_URL` (Docker build arg) | empty | Direct link to a static Vantage release to bake into the image. |
**Game server settings**
| Where | Key | Default |
| --- | --- | --- |
| Paper `config.yml` | `livemap.enabled` | `true` |
| Fabric/Forge `config/scopenet.properties` | `livemap.enabled` | `true` |
| Panel, per server | Live Map switch | on for new servers |
**What the panel runs for each map** (for reference):
```
vantage server <data>/livemap/<id>/world --assets <assets> --out <cache> \
--host 127.0.0.1 --port <random> --players-file <players.json> --dimension <dim> <your extra flags>
```
with the secret passed in `VANTAGE_SERVER_TOKEN`.
**Not verified here:** the exact names of Vantage's release files and the exact folder layout that `vantage extract` produces change between Vantage versions. This guide was written against Vantage's published README and server documentation; follow Vantage's own `--help` if they differ.
+33 -4
View File
@@ -21,15 +21,31 @@ social.chat_prefixes=true
essentials.enabled=true
economy.enabled=true
# Send world chunks and player positions to the panel's Live Map
# (also needs Live Map switched on for this server in the panel).
livemap.enabled=true
# Draw this world on the SCOPENET Map: tiles, player positions, guild land and pins go to the panel
# (also needs the map switched on for this server in the panel, which it is by default).
map.enabled=true
# --- Essentials (1.20.1) -----------------------------------------------------
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
# scopenet.homes.25 gives 25 (highest wins), scopenet.homes.unlimited removes the limit.
essentials.max_homes=5
essentials.rtp_radius=2500
essentials.home_name_max_length=32
# Most warps the server may have (0 = no limit).
essentials.max_warps=0
essentials.tpa_timeout_seconds=60
# /rtp picks a random safe spot between rtp_min_radius and rtp_radius blocks from 0,0.
essentials.rtp_radius=2500
essentials.rtp_min_radius=0
essentials.rtp_attempts=15
# Seconds between uses (0 = none). Starts when the command actually teleports.
# Players with scopenet.cooldown.bypass skip all of these.
essentials.cooldown.spawn=0
essentials.cooldown.home=0
essentials.cooldown.back=0
essentials.cooldown.tpa=0
essentials.cooldown.rtp=0
essentials.cooldown.warp=0
# --- Economy (1.20.1) --------------------------------------------------------
# Shown next to amounts.
@@ -52,3 +68,16 @@ placeholders.enabled=true
# --- Client mod link (1.20.1) ------------------------------------------------
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
clientlink.enabled=true
# --- Map pins ----------------------------------------------------------------
# Show every player's homes as pins on the map. Off by default: homes are private.
map.show_homes=false
# --- Rewards -----------------------------------------------------------------
# Quests, achievements and rank milestones can give items, permissions, groups, messages... (set in the admin panel).
rewards.enabled=true
# Let the panel run its own console commands as rewards. Leave off unless you trust every panel admin.
rewards.allow_commands=false
rewards.permission_command=lp user {player} permission set {node} true
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
rewards.group_command=lp user {player} parent add {group}
+45 -7
View File
@@ -58,17 +58,38 @@ social:
essentials:
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
enabled: true
# Homes per player.
# Homes every player can set. Raise it for a rank with a permission node:
# scopenet.homes.10 gives 10 homes, scopenet.homes.25 gives 25 (highest wins),
# scopenet.homes.unlimited removes the limit. Operators and "*" get the top tier.
max-homes: 5
# /rtp picks a random safe spot within this many blocks of 0,0.
# Longest allowed home name.
home-name-max-length: 32
# Most warps the server may have (0 = no limit).
max-warps: 0
# How long a /tpa request stays open.
tpa-timeout-seconds: 60
# /rtp picks a random safe spot between rtp-min-radius and rtp-radius blocks from 0,0.
rtp-radius: 2500
rtp-min-radius: 0
# Random spots tried before /rtp gives up.
rtp-attempts: 15
# Seconds between uses (0 = none). Starts when the command actually teleports.
# Players with scopenet.cooldown.bypass skip all of these.
cooldowns:
spawn: 0
home: 0
back: 0
tpa: 0
rtp: 0
warp: 0
livemap:
# Send world chunks and player positions to the panel's central 3D Live Map.
# Nothing runs on this server; the panel renders it. Needs "Live Map" switched on
# for this server in the panel, and Vantage installed on the panel.
# Setup guide: docs/vantage-setup.md
map:
# The SCOPENET Map: this server draws its world to map tiles in the background and
# sends only what changed, with players, guild land and pins, to the panel.
# It also needs the map switched on for this server in the panel (on by default).
enabled: true
# Show every player's homes as pins. Off by default: homes are private.
show-homes: false
economy:
# /balance /pay /baltop /shop /sell /market /orders /trade /transactions
@@ -114,6 +135,23 @@ integrations:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# Rewards
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
# to this server when the player is online. Permissions and groups use console commands, so any permissions
# plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
# ==============================================================================
rewards:
enabled: true
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the
# panel could otherwise run commands on this server. Only switch it on if you trust every panel admin.
allow-commands: false
permission-command: "lp user {player} permission set {node} true"
permission-deny-command: "lp user {player} permission set {node} false"
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
group-command: "lp user {player} parent add {group}"
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -36,7 +36,9 @@ public record ScopenetPlayer(
QuestProgress weeklyQuests,
String joined,
String rankGroup,
String rankPrefix) {
String rankDisplay,
String rankPrefix,
String rankSuffix) {
public boolean inGuild() { return guild != null; }
+1 -1
View File
@@ -1,6 +1,6 @@
allprojects {
group = 'net.scopenet'
version = providers.gradleProperty('releaseVersion').getOrElse('0.4.0')
version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
repositories { mavenCentral() }
}
subprojects {
@@ -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 -> { }
}
}
}
@@ -189,6 +189,16 @@ public final class ClientLink {
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;
@@ -8,15 +8,23 @@ import java.util.List;
public final class CommandSet {
public final EssentialsService essentials;
public final Jobs jobs;
public final ShopPoints shops;
private final GuildCommands guildCommands;
private final EconomyCommands economyCommands;
private final List<CoreCommand> commands = new ArrayList<>();
private final Limits.Cooldowns cooldowns = new Limits.Cooldowns();
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir;
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
EconomyCommands economy = new EconomyCommands(env, jobs);
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"));
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
economyCommands = economy;
guildCommands = guilds;
add(env, EssentialsCommands.build(env, essentials).stream().<CoreCommand>map(c -> new Cooled(c, essentials, cooldowns, env.clock)).toList(),
"Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
}
@@ -27,9 +35,51 @@ public final class CommandSet {
public List<CoreCommand> all() { return List.copyOf(commands); }
/** Run a command by name as this player, with the same checks as typing it. */
public void run(String name, CorePlayer player, String... args) {
for (CoreCommand c : commands) if (c.name().equals(name)) { c.run(player, args); return; }
throw new IllegalArgumentException("no command " + name);
}
/** What this command set knows that the map can show. */
public net.scopenet.core.map.MapSource mapSource() {
return new net.scopenet.core.map.MapSource() {
@Override public java.util.Map<String, Pos> warps() { return essentials.allWarps(); }
@Override public java.util.Map<java.util.UUID, java.util.Map<String, Pos>> homes() { return essentials.allHomes(); }
@Override public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
@Override public java.util.Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
}
/** Guild homes by guild id (for the map). */
public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
/** Call about once a second from the server thread. */
public void tick() { jobs.tick(); }
/**
* Teleport commands can have a cooldown ({@code essentials.cooldowns}). It starts when the command actually teleports (or sends a
* request), so a typo doesn't cost the player anything. Players with {@code scopenet.cooldown.bypass} skip it.
*/
private record Cooled(CoreCommand inner, EssentialsService essentials, Limits.Cooldowns cooldowns, java.util.function.LongSupplier clock) implements CoreCommand {
@Override public String name() { return inner.name(); }
@Override public List<String> aliases() { return inner.aliases(); }
@Override public String permission() { return inner.permission(); }
@Override public void run(CorePlayer p, String[] args) {
int seconds = essentials.cooldownSeconds(inner.name());
boolean cooled = seconds > 0 && !p.hasPermission("scopenet.cooldown.bypass");
long now = clock.getAsLong();
if (cooled) {
long wait = cooldowns.remainingMs(p.uuid(), inner.name(), now);
if (wait > 0) { p.send(Format.RED + "Please wait " + Limits.Cooldowns.waitText(wait) + " before using /" + inner.name() + " again."); return; }
}
long before = essentials.lastAction(p.uuid());
inner.run(p, args);
if (cooled && essentials.lastAction(p.uuid()) != before) cooldowns.start(p.uuid(), inner.name(), now, seconds);
}
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
}
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
@Override public String name() { return inner.name(); }
@@ -14,8 +14,9 @@ import java.util.*;
final class EconomyCommands {
private final Env env;
private final Jobs jobs;
private final ShopPoints shops;
EconomyCommands(Env env, Jobs jobs) { this.env = env; this.jobs = jobs; }
EconomyCommands(Env env, Jobs jobs, ShopPoints shops) { this.env = env; this.jobs = jobs; this.shops = shops; }
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
@@ -24,7 +25,7 @@ final class EconomyCommands {
out.add(Cmd.of("baltop", List.of("richest"), (p, a) -> baltop(p)));
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(p)));
out.add(Cmd.of("sell", List.of(), (p, a) -> sell(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("hand") : List.of()));
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy") : List.of()));
out.add(Cmd.of("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)));
@@ -120,6 +121,12 @@ final class EconomyCommands {
/** {@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;
@@ -1,5 +1,21 @@
package net.scopenet.core;
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
import java.util.Map;
/**
* Every tunable of homes, warps, TPA and RTP. Players can exceed {@code maxHomes} with a permission node
* ({@code scopenet.homes.<number>}, or {@code scopenet.homes.unlimited}); {@code cooldowns} are seconds per command
* (0 = none) and are skipped for anyone with {@code scopenet.cooldown.bypass}.
*/
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts, int rtpMinRadius, int maxWarps,
int homeNameMax, Map<String, Integer> cooldowns) {
public static final int TIER_CAP = 500;
public EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
this(maxHomes, rtpRadius, tpaTimeoutMs, rtpAttempts, 0, 0, 32, Map.of());
}
public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
public int cooldown(String command) { return cooldowns == null ? 0 : cooldowns.getOrDefault(command, 0); }
}
@@ -47,6 +47,8 @@ public final class EssentialsService {
public void configure(EssentialsConfig next) { this.config = next; }
public int cooldownSeconds(String command) { return config.cooldown(command); }
// ---- persistence -------------------------------------------------------
private void load() {
@@ -113,8 +115,10 @@ public final class EssentialsService {
public void setHome(CorePlayer p, String[] args) {
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
Map<String, Pos> mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>());
if (!mine.containsKey(name) && mine.size() >= config.maxHomes()) {
p.send(RED + "You have reached your limit of " + config.maxHomes() + " homes.");
if (name.length() > config.homeNameMax()) { p.send(RED + "Home names can be at most " + config.homeNameMax() + " characters."); return; }
int limit = Limits.tier(p::hasPermission, "scopenet.homes.", config.maxHomes(), EssentialsConfig.TIER_CAP);
if (!mine.containsKey(name) && mine.size() >= limit) {
p.send(RED + "You have reached your limit of " + Limits.show(limit) + " homes.");
return;
}
mine.put(name, p.pos());
@@ -132,6 +136,16 @@ public final class EssentialsService {
// ---- 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))); }
@@ -151,6 +165,10 @@ public final class EssentialsService {
}
public void setWarp(CorePlayer p, String name) {
if (config.maxWarps() > 0 && !warps.containsKey(name.toLowerCase(Locale.ROOT)) && warps.size() >= config.maxWarps()) {
p.send(RED + "The server already has " + config.maxWarps() + " warps. Delete one first.");
return;
}
warps.put(name.toLowerCase(Locale.ROOT), p.pos());
save();
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set.");
@@ -180,6 +198,7 @@ public final class EssentialsService {
CorePlayer target = found.get();
if (target.uuid().equals(p.uuid())) { p.send(RED + "You cannot teleport to yourself."); return; }
pendingTpa.put(target.uuid(), Map.entry(p.uuid(), clock.getAsLong()));
lastAction.put(p.uuid(), clock.getAsLong());
p.send(GREEN + "Teleport request sent to " + YELLOW + target.name() + GREEN + ".");
target.send(YELLOW + p.name() + GREEN + " has sent you a teleport request.");
target.send(GRAY + "Type " + YELLOW + "/tpaccept" + GRAY + " to accept or " + RED + "/tpdeny" + GRAY
@@ -213,11 +232,13 @@ public final class EssentialsService {
/** Must run on the server thread, because it reads chunks. */
public void rtp(CorePlayer p) {
int radius = config.rtpRadius();
int min = Math.min(config.rtpMinRadius(), radius - 1);
String world = p.pos().world();
p.send(GRAY + "Searching for a safe wilderness destination...");
for (int i = 0; i < config.rtpAttempts(); i++) {
int x = rng.nextInt(radius * 2) - radius;
int z = rng.nextInt(radius * 2) - radius;
if (Math.max(Math.abs(x), Math.abs(z)) < min) continue; // too close to the centre
Optional<Pos> spot = platform.safeSurface(world, x, z);
if (spot.isPresent()) {
Pos at = spot.get();
@@ -232,7 +253,13 @@ public final class EssentialsService {
/** Teleport and remember where they were for /back. */
public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); }
/** When a player last teleported or sent a teleport request (for cooldowns). */
public long lastAction(UUID id) { return lastAction.getOrDefault(id, -1L); }
private final Map<UUID, Long> lastAction = new ConcurrentHashMap<>();
private void go(CorePlayer p, Pos destination) {
lastAction.put(p.uuid(), clock.getAsLong());
remember(p.uuid(), p.pos());
p.teleport(destination);
}
@@ -15,7 +15,7 @@ import java.util.concurrent.ConcurrentHashMap;
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
final class GuildCommands {
private static final Gson GSON = new Gson();
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay");
private 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;
@@ -39,7 +39,8 @@ final class GuildCommands {
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay") : List.of()));
? 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;
@@ -78,6 +79,9 @@ final class GuildCommands {
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);
@@ -93,6 +97,7 @@ final class GuildCommands {
{"/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]);
}
@@ -254,6 +259,30 @@ final class GuildCommands {
});
}
// ---- 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) {
@@ -0,0 +1,40 @@
package net.scopenet.core;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Predicate;
/** Per-player limits that permission nodes can raise, and command cooldowns. */
public final class Limits {
private Limits() {}
/**
* The highest tier a player has, e.g. {@code scopenet.homes.10}, otherwise {@code base}. {@code <prefix>unlimited}
* means no limit. Only values above {@code base} are looked for, up to {@code cap}.
*/
public static int tier(Predicate<String> has, String prefix, int base, int cap) {
if (has.test(prefix + "unlimited")) return Integer.MAX_VALUE;
for (int n = cap; n > base; n--) if (has.test(prefix + n)) return n;
return base;
}
public static String show(int limit) { return limit == Integer.MAX_VALUE ? "unlimited" : Integer.toString(limit); }
/** When each player may use each command again. Not thread-safe: use from the server thread. */
public static final class Cooldowns {
private final Map<String, Long> until = new HashMap<>();
public long remainingMs(UUID player, String command, long now) {
Long end = until.get(player + ":" + command);
return end == null ? 0 : Math.max(0, end - now);
}
public void start(UUID player, String command, long now, int seconds) {
if (seconds > 0) until.put(player + ":" + command, now + seconds * 1000L);
if (until.size() > 4000) until.values().removeIf(end -> end < now);
}
public static String waitText(long ms) { return Math.max(1, (ms + 999) / 1000) + "s"; }
}
}
@@ -19,4 +19,10 @@ public interface Platform {
void runMain(Runnable task);
/** Run off the server thread, for network calls. */
void runAsync(Runnable task);
/** Put items in the player's inventory (anything that doesn't fit drops at their feet). False if the item doesn't exist here. */
default boolean giveItem(UUID player, String itemId, int amount) { return false; }
/** Run a command as the console; true if it was found and run. Called on the server thread. */
default boolean console(String command) { return false; }
}
@@ -59,22 +59,22 @@ public final class PlayerCache {
// ---- small accessors used by placeholders and the client link -----------------------------
static JsonObject obj(JsonObject o, String key) {
public static JsonObject obj(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
}
static double num(JsonObject o, String key, double fallback) {
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;
}
static String str(JsonObject o, String key, String 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;
}
static JsonArray arr(JsonObject o, String key) {
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,122 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Optional;
import java.util.UUID;
/**
* Collects the rewards the panel owes to players on this server (items, permissions, groups, commands, messages) and carries
* them out, then tells the panel what worked. The panel only hands rewards over for players who are online here.
*/
public final class RewardPoller {
public static final long EVERY_MS = 5_000;
private final Env env;
private volatile RewardRules rules;
private long next;
private volatile boolean running;
public RewardPoller(Env env, RewardRules rules) { this.env = env; this.rules = rules; }
public void rules(RewardRules rules) { this.rules = rules; }
/** Call often on the server thread; it asks the panel at most every few seconds. */
public void tick() {
if (!rules.enabled()) return;
long now = env.clock.getAsLong();
if (running || now < next) return;
next = now + EVERY_MS;
running = true;
env.platform.runAsync(() -> {
try {
JsonElement answer = env.panel.call("rewards/poll", new JsonObject());
JsonArray list = answer.isJsonObject() && answer.getAsJsonObject().has("deliveries") ? answer.getAsJsonObject().getAsJsonArray("deliveries") : new JsonArray();
if (list.size() > 0) env.platform.runMain(() -> carryOut(list));
} catch (Exception e) {
// The panel is briefly unreachable: try again on the next round.
} finally {
running = false;
}
});
}
private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; }
/** Server thread. Applies each delivery, then reports back from the background. */
void carryOut(JsonArray list) {
JsonArray done = new JsonArray(), failed = new JsonArray();
for (JsonElement el : list) {
JsonObject d = el.getAsJsonObject();
long id = d.get("id").getAsLong();
String error;
try {
error = apply(d);
} catch (RuntimeException e) {
error = e.getClass().getSimpleName();
}
if (error == null) done.add(id);
else {
JsonObject f = new JsonObject();
f.addProperty("id", id);
f.addProperty("error", error);
failed.add(f);
}
}
JsonObject body = new JsonObject();
body.add("done", done);
body.add("failed", failed);
env.platform.runAsync(() -> {
try { env.panel.call("rewards/ack", body); } catch (Exception e) { /* the panel re-offers it, and a second try is harmless */ }
});
}
/** Null when carried out, otherwise why not. */
String apply(JsonObject d) {
UUID uuid;
try { uuid = UUID.fromString(text(d, "uuid")); } catch (IllegalArgumentException e) { return "bad player id"; }
Optional<CorePlayer> player = env.platform.player(uuid);
if (player.isEmpty()) return "player left"; // retried when they are back
JsonObject a = d.has("payload") && d.get("payload").isJsonObject() ? d.getAsJsonObject("payload") : new JsonObject();
String name = player.get().name();
RewardRules r = rules;
switch (text(d, "kind")) {
case "item" -> {
int amount = a.has("amount") ? a.get("amount").getAsInt() : 1;
if (!env.platform.giveItem(uuid, text(a, "item"), amount)) return "unknown item " + text(a, "item");
player.get().send(Format.GREEN + "You received " + Format.WHITE + amount + "x " + prettyItem(text(a, "item")) + Format.GREEN + "!");
}
case "permission" -> {
boolean value = !a.has("value") || a.get("value").getAsBoolean();
long minutes = a.has("minutes") ? a.get("minutes").getAsLong() : 0;
String template = minutes > 0 ? r.permissionTempCommand() : value ? r.permissionCommand() : r.permissionDenyCommand();
String command = template.replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{node}", text(a, "node"))
.replace("{value}", Boolean.toString(value)).replace("{duration}", minutes + "m");
if (!env.platform.console(command)) return "the permissions command did not run (is LuckPerms installed?)";
player.get().send(Format.GREEN + "You gained a new permission: " + Format.WHITE + text(a, "node") + Format.GREEN + ".");
}
case "group" -> {
String command = r.groupCommand().replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{group}", text(a, "group"));
if (!env.platform.console(command)) return "the group command did not run (is LuckPerms installed?)";
player.get().send(Format.GREEN + "You joined the " + Format.WHITE + text(a, "group") + Format.GREEN + " group.");
}
case "command" -> {
if (!r.allowCommands()) return "custom commands are switched off on this server (rewards.allow-commands)";
String command = text(a, "command").replace("{player}", name).replace("{uuid}", uuid.toString());
if (!env.platform.console(command)) return "command not found";
}
case "message" -> player.get().send(Format.GOLD + text(a, "text"));
default -> { return "unknown reward kind"; }
}
return null;
}
private static String prettyItem(String id) {
String n = id.contains(":") ? id.substring(id.indexOf(':') + 1) : id;
StringBuilder out = new StringBuilder();
for (String w : n.split("[_/]")) if (!w.isEmpty()) out.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1)).append(' ');
return out.toString().trim();
}
}
@@ -0,0 +1,13 @@
package net.scopenet.core;
/**
* How this server carries out rewards the panel hands over. Permissions and groups go through console commands so any
* permissions plugin works; the defaults are LuckPerms'. Custom admin commands stay off unless the server owner allows them.
*/
public record RewardRules(boolean enabled, boolean allowCommands, String permissionCommand, String permissionDenyCommand,
String permissionTempCommand, String groupCommand) {
public static RewardRules defaults() {
return new RewardRules(true, false, "lp user {player} permission set {node} true", "lp user {player} permission set {node} false",
"lp user {player} permission settemp {node} {value} {duration}", "lp user {player} parent add {group}");
}
}
@@ -0,0 +1,99 @@
package net.scopenet.core;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Physical shops: a block in the world that opens the market or the server shop when a player right-clicks it, and
* shows up as a pin on the map. The GUI itself is the same as typing the command, so it works on Paper (chest window)
* and with the client mod (shop and market windows). Set up by staff with {@code /market point add|remove|list}.
*/
public final class ShopPoints {
public record Shop(String name, String kind, Pos pos) {}
public static final String NODE = "scopenet.command.market.point";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path file;
private final Map<String, Shop> shops = new ConcurrentHashMap<>();
public ShopPoints(Path file) {
this.file = file;
if (Files.exists(file)) {
try (Reader in = Files.newBufferedReader(file)) {
List<Shop> loaded = GSON.fromJson(in, new TypeToken<List<Shop>>() {}.getType());
if (loaded != null) loaded.forEach(s -> shops.put(s.name().toLowerCase(Locale.ROOT), s));
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
}
}
}
public Collection<Shop> all() { return new ArrayList<>(new TreeMap<>(shops).values()); }
/** The shop at this block, if any. */
public Optional<Shop> at(String dimension, int x, int y, int z) {
for (Shop s : shops.values()) {
Pos p = s.pos();
if (p.world().equals(dimension) && p.blockX() == x && p.blockY() == y && p.blockZ() == z) return Optional.of(s);
}
return Optional.empty();
}
private void save() {
try {
if (file.getParent() != null) Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(all(), w); }
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
}
}
/**
* {@code /market point add <name> [market|shop]} marks the block the player is looking at... or, where the platform can't
* tell, the block they stand on. {@code target} is that block. {@code remove <name>} and {@code list} as expected.
*/
public void command(CorePlayer p, String[] args, Pos target) {
if (!p.hasPermission(NODE)) { p.send(Format.RED + "You do not have permission to manage shop points."); return; }
String sub = args.length == 0 ? "list" : args[0].toLowerCase(Locale.ROOT);
switch (sub) {
case "add" -> {
if (args.length < 2) { p.send(Format.RED + "Usage: /market point add <name> [market|shop]"); return; }
String name = args[1];
if (!name.matches("[A-Za-z0-9_-]{1,24}")) { p.send(Format.RED + "Names are 1-24 letters, digits, - or _."); return; }
String kind = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : "market";
if (!kind.equals("market") && !kind.equals("shop")) { p.send(Format.RED + "The kind is market or shop."); return; }
if (at(target.world(), target.blockX(), target.blockY(), target.blockZ()).isPresent()) { p.send(Format.RED + "There is already a shop point on that block."); return; }
Pos block = new Pos(target.world(), target.blockX() + 0.5, target.blockY(), target.blockZ() + 0.5, 0, 0);
shops.put(name.toLowerCase(Locale.ROOT), new Shop(name, kind, block));
save();
p.send(Format.GREEN + "Shop point " + Format.YELLOW + name + Format.GREEN + " set (" + kind + "). Right-click that block to open the "
+ (kind.equals("market") ? "market" : "shop") + ".");
}
case "remove", "delete" -> {
if (args.length < 2) { p.send(Format.RED + "Usage: /market point remove <name>"); return; }
if (shops.remove(args[1].toLowerCase(Locale.ROOT)) == null) { p.send(Format.RED + "No shop point called '" + args[1] + "'."); return; }
save();
p.send(Format.GREEN + "Shop point " + Format.YELLOW + args[1] + Format.GREEN + " removed.");
}
case "list" -> {
p.send(Format.GOLD + "=== Shop points ===");
if (shops.isEmpty()) p.send(Format.GRAY + "None yet. Look at a block and use /market point add <name>.");
for (Shop s : all()) p.send(Format.YELLOW + s.name() + Format.GRAY + " (" + s.kind() + ") " + s.pos().blockX() + ", " + s.pos().blockY() + ", " + s.pos().blockZ());
}
default -> p.send(Format.RED + "Usage: /market point add <name> [market|shop] | remove <name> | list");
}
}
}
@@ -0,0 +1,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 world id or name means ("world_nether" -> the nether). */
public static String dimensionOf(String id, String name) {
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
if (words.contains("nether")) return "minecraft:the_nether";
if (words.contains("end")) return "minecraft:the_end";
return "minecraft:overworld";
}
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
public static String iconAddress(String icon, String panelBase) {
if (icon == null || icon.isBlank()) return "";
String i = icon.trim();
if (i.startsWith("https://") || i.startsWith("http://")) return i;
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
return "";
}
public static MapData build(Sources s) {
List<MapData.ClaimRegion> claims = new ArrayList<>();
s.claims().forEach((dimension, guilds) -> {
for (ClaimIndex.GuildClaims g : guilds) {
int n = 0;
for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) {
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()),
guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail));
}
}
});
List<MapData.Pin> pins = new ArrayList<>();
int i = 0;
for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(),
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(),
home.pos().x(), home.pos().y(), home.pos().z(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
if (s.showHomes()) {
s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> {
String who = s.homeOwners().getOrDefault(owner, "a player");
pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
}));
}
String marketDetail = marketSummary(s.market());
for (ShopPoints.Shop shop : s.shops()) {
boolean market = shop.kind().equals("market");
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
Pos p = shop.pos();
pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail));
}
pins.sort(Comparator.comparing(MapData.Pin::id));
return new MapData(claims, pins, s.players());
}
/** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */
static String marketSummary(JsonArray listings) {
if (listings == null || listings.size() == 0) return "<br><em>No listings right now.</em>";
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
int shown = 0;
for (JsonElement e : listings) {
if (shown++ >= 5) break;
JsonObject l = e.getAsJsonObject();
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name");
b.append("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
.append(" &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,81 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Locale;
/**
* The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text
* lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup.
*/
public final class MapOverlay {
private MapOverlay() {}
public static JsonObject toJson(MapData data) {
JsonArray claims = new JsonArray();
for (MapData.ClaimRegion c : data.claims()) {
JsonObject o = new JsonObject();
o.addProperty("id", c.id());
o.addProperty("guild_id", c.guildId());
o.addProperty("name", c.name());
o.addProperty("tag", c.tag());
o.addProperty("icon", c.icon());
o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF));
o.addProperty("dimension", c.dimension());
o.addProperty("chunks", c.chunks());
o.addProperty("label_x", c.labelX());
o.addProperty("label_z", c.labelZ());
o.add("outer", ring(c.outer()));
JsonArray holes = new JsonArray();
for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole));
o.add("holes", holes);
o.add("lines", lines(c.detail()));
claims.add(o);
}
JsonArray pins = new JsonArray();
for (MapData.Pin p : data.pins()) {
JsonObject o = new JsonObject();
o.addProperty("id", p.id());
o.addProperty("kind", p.kind());
o.addProperty("label", p.label());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.add("lines", lines(p.detail()));
pins.add(o);
}
JsonObject root = new JsonObject();
root.add("claims", claims);
root.add("pins", pins);
return root;
}
private static JsonArray ring(ChunkRegions.Ring ring) {
JsonArray out = new JsonArray();
for (ChunkRegions.Point p : ring.points()) {
JsonArray pair = new JsonArray();
pair.add(p.x());
pair.add(p.z());
out.add(pair);
}
return out;
}
/** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */
static JsonArray lines(String html) {
String text = html == null ? "" : html
.replaceAll("(?i)<br\\s*/?>|</div>|</li>|</ul>|<ul[^>]*>", "\n")
.replaceAll("<[^>]*>", "");
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.replace("&middot;", "·").replace("&amp;", "&");
JsonArray out = new JsonArray();
for (String line : text.split("\n")) {
String t = line.trim();
if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t);
}
return out;
}
}
@@ -0,0 +1,78 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.core.*;
import net.scopenet.integration.ClaimIndex;
import java.util.*;
/**
* Gathers everything the map needs, from any thread. The map overlay asks for a {@link MapData} every
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
*/
public final class MapService {
public static final long MARKET_REFRESH_MS = 60_000;
private final Env env;
private final ClaimIndex claims;
private final MapSource places;
private final PlayerCache cache;
private final String panelBase;
private final boolean showHomes;
private final String serverName;
private volatile JsonArray market = new JsonArray();
private volatile long marketAt = Long.MIN_VALUE / 2;
private volatile boolean fetching;
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
}
public String serverName() { return serverName; }
/** Server thread, about once a second. */
public void tick() {
long now = env.clock.getAsLong();
if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return;
fetching = true;
marketAt = now;
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
fetching = false;
}, e -> fetching = false);
}
/** Safe from any thread: reads only copies and concurrent collections. */
public MapData snapshot() {
List<MapData.OnlinePlayer> players = onlinePlayers();
Map<UUID, String> names = new HashMap<>();
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
Map<String, String> labels = new HashMap<>();
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
MapModel.Sources sources = new MapModel.Sources(
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
env.features.essentials() ? places.warps() : Map.of(),
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
market, players, panelBase, showHomes, names);
return MapModel.build(sources);
}
private List<MapData.OnlinePlayer> onlinePlayers() {
List<MapData.OnlinePlayer> out = new ArrayList<>();
for (CorePlayer p : env.platform.online()) {
JsonObject i = cache.info(p.uuid());
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank");
out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""),
(long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""),
PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0)));
}
return out;
}
public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); }
}
@@ -0,0 +1,17 @@
package net.scopenet.core.map;
import net.scopenet.core.Pos;
import net.scopenet.core.ShopPoints;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
public interface MapSource {
Map<String, Pos> warps();
Map<UUID, Map<String, Pos>> homes();
/** Guild homes by guild id. */
Map<String, Pos> guildHomes();
Collection<ShopPoints.Shop> shops();
}
@@ -16,7 +16,7 @@ import java.util.UUID;
* The panel is polled for a new copy only when its revision changes.
*/
public final class ClaimIndex {
private record Guild(String id, String name, String tag) {}
private record 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) {}
@@ -59,6 +59,26 @@ public final class ClaimIndex {
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;
@@ -67,7 +87,8 @@ public final class ClaimIndex {
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());
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");
@@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable {
private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed;
private volatile Consumer<JsonArray> notificationHandler;
private volatile LiveMapSync liveMap;
private volatile net.scopenet.worldmap.WorldMapSync map;
private volatile java.util.function.Supplier<String> mapOverlay;
private boolean registered;
private JsonObject pending;
private long nextSync;
@@ -145,25 +146,33 @@ public final class Integration implements AutoCloseable {
}
/**
* Start mirroring this server's world and player positions to the panel's
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
* Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims
* overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles.
*/
public void enableLiveMap(java.nio.file.Path worldRoot) {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client, worldRoot, log);
liveMap.start();
public void enableMap(java.nio.file.Path worldRoot, java.nio.file.Path cacheDir) {
if (closed || map != null) return;
map = new net.scopenet.worldmap.WorldMapSync(client, worldRoot, cacheDir, log);
if (mapOverlay != null) map.setOverlay(mapOverlay);
map.start();
}
/** Where the map gets its claims and pins (JSON text); may be called before or after {@link #enableMap}. */
public void setMapOverlay(java.util.function.Supplier<String> overlay) {
mapOverlay = overlay;
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.setOverlay(overlay);
}
/** Hand over a snapshot of player positions; call about once a second on the server thread. */
public void liveMapPlayers(java.util.List<LiveMapSync.Player> players) {
LiveMapSync map = liveMap;
if (map != null) map.offerPlayers(players);
public void mapPlayers(java.util.List<net.scopenet.worldmap.WorldMapSync.Player> players) {
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.offerPlayers(players);
}
/** The world was just saved: pick up its changed chunks soon. */
public void liveMapSaved() {
LiveMapSync map = liveMap;
if (map != null) map.requestScan();
/** The world was just saved: pick up its changed regions soon. */
public void mapSaved() {
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.requestScan();
}
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
@@ -182,8 +191,8 @@ public final class Integration implements AutoCloseable {
@Override public void close() {
closed = true;
LiveMapSync map = liveMap;
if (map != null) map.close();
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.close();
client.close();
loginWorker.shutdownNow();
activity.leaveAll();
@@ -1,363 +0,0 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
* already-compressed Anvil payloads read straight from the region files) over
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
* Nothing here touches the server thread except {@link #offerPlayers}.
*/
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private static final String[] DIMS = {"overworld", "the_nether", "the_end"};
private static final int SECTOR = 4096;
private static final int BATCH_BYTES = 6 * 1024 * 1024;
private static final int SCAN_BUDGET_BYTES = 48 * 1024 * 1024;
private final PanelClient client;
private final Path root;
private final Consumer<String> log;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-livemap");
t.setDaemon(true);
return t;
});
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
private final Map<String, int[]> known = new HashMap<>();
private final Set<String> manifestLoaded = new HashSet<>();
private final AtomicBoolean scanQueued = new AtomicBoolean();
private volatile boolean panelEnabled;
private volatile boolean closed;
private volatile List<Player> players = List.of();
private volatile boolean playersChanged;
private volatile WebSocket socket;
private volatile long socketRetryAt;
private long nextScan;
private long configCheckAt;
private long levelModified = -1;
private long lastPlayersSent;
private long chunkIntervalSecs = 20;
private boolean warnedMissing;
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
this.client = client;
this.root = root;
this.log = log;
}
public void start() {
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
playersChanged = true;
}
}
/** A save just happened; scan soon instead of waiting for the next interval. */
public void requestScan() {
if (closed || !scanQueued.compareAndSet(false, true)) return;
try {
worker.schedule(() -> {
scanQueued.set(false);
nextScan = 0;
scanSafely();
}, 3, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
scanQueued.set(false);
}
}
@Override public void close() {
closed = true;
WebSocket ws = socket;
if (ws != null) ws.abort();
worker.shutdownNow();
}
// ---- chunks ----
private void scanSafely() {
if (closed || System.nanoTime() < nextScan) return;
try {
if (!refreshEnabled()) return;
long uploaded = scan();
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
}
}
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
private boolean refreshEnabled() throws IOException, InterruptedException {
if (panelEnabled) return true;
long now = System.nanoTime();
if (now < configCheckAt) return false;
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
try {
JsonObject config = client.get("livemap/config");
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
} catch (PanelClient.HttpFailure e) {
panelEnabled = false; // an older panel without live map support
}
return panelEnabled;
}
/** Returns how many bytes of chunk data were uploaded. */
private long scan() throws IOException, InterruptedException {
if (!Files.isDirectory(root)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET live map: world folder " + root + " not found");
}
return 0;
}
uploadLevelIfChanged();
long total = 0;
for (String dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
loadManifest(dim);
Batch batch = new Batch(dim);
List<Path> files = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
stream.forEach(files::add);
}
files.sort(Comparator.comparing(Path::toString));
for (Path file : files) {
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
total += readRegion(dim, file, batch);
}
total += batch.flush();
if (total >= SCAN_BUDGET_BYTES) break;
}
return total;
}
private Path regionDir(String dim) {
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
Path parent = root.toAbsolutePath().getParent();
List<Path> candidates = switch (dim) {
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
root.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
root.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
private void loadManifest(String dim) throws IOException, InterruptedException {
if (manifestLoaded.contains(dim)) return;
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
for (JsonElement el : manifest.getAsJsonArray("regions")) {
JsonObject region = el.getAsJsonObject();
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
int[] ts = new int[1024];
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
}
manifestLoaded.add(dim);
}
private static String key(String dim, int rx, int rz) {
return dim + ":" + rx + ":" + rz;
}
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
return 0;
}
long flushed = 0;
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long size = ch.size();
if (size < 2L * SECTOR) return 0;
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
for (int i = 0; i < 1024; i++) {
int loc = header.getInt(i * 4);
int ts = header.getInt(SECTOR + i * 4);
int count = loc & 0xff;
long offset = (loc >>> 8) & 0xffffffL;
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
if (offset < 2 || (offset + count) * SECTOR > size) continue;
ByteBuffer prefix = ByteBuffer.allocate(5);
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
if (prefix.hasRemaining()) continue;
int length = prefix.getInt(0);
int compression = prefix.get(4) & 0xff;
// Oversized chunks live in external .mcc files; skip those and anything malformed.
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
ByteBuffer data = ByteBuffer.allocate(length - 1);
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
if (data.hasRemaining()) continue;
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
}
}
return flushed;
}
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
private final class Batch {
final String dim;
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<int[]> marks = new ArrayList<>();
int size;
Batch(String dim) {
this.dim = dim;
}
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
out.write(rec.array(), 0, rec.capacity());
marks.add(new int[]{rx, rz, index, ts});
size += rec.capacity();
}
long flush() throws IOException, InterruptedException {
if (marks.isEmpty()) return 0;
long sent = size;
try {
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
} finally {
out.reset();
marks.clear();
size = 0;
}
return sent;
}
}
private void uploadLevelIfChanged() throws IOException, InterruptedException {
Path level = root.resolve("level.dat");
if (!Files.isRegularFile(level)) return;
long modified = Files.getLastModifiedTime(level).toMillis();
if (modified == levelModified) return;
byte[] bytes = Files.readAllBytes(level);
if (bytes.length > 2 * 1024 * 1024) return;
client.postBytes("livemap/level", bytes);
levelModified = modified;
}
// ---- players ----
private void sendPlayersSafely() {
if (closed || !panelEnabled) return;
long now = System.nanoTime();
// Resend unchanged rosters now and then so the panel knows we're alive.
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
if (!playersChanged && !heartbeat) return;
playersChanged = false;
lastPlayersSent = now;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.addProperty("yaw", p.yaw());
o.addProperty("pitch", p.pitch());
list.add(o);
}
JsonObject body = new JsonObject();
body.addProperty("type", "players");
body.add("players", list);
try {
WebSocket ws = socket;
if (ws != null && !ws.isOutputClosed()) {
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
return;
}
connectSocket();
client.post("livemap/players", body);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
socket = null; // reconnect later; HTTPS covers the gap
}
}
private void connectSocket() {
long now = System.nanoTime();
if (now < socketRetryAt) return;
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
try {
Settings settings = client.settings();
URI panel = settings.panel();
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
client.http().newWebSocketBuilder()
.header("Authorization", "Bearer " + settings.token())
.connectTimeout(Duration.ofSeconds(5))
.buildAsync(uri, new WebSocket.Listener() {
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
ws.request(1);
return null;
}
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
if (socket == ws) socket = null;
return null;
}
@Override public void onError(WebSocket ws, Throwable error) {
if (socket == ws) socket = null;
}
}).thenAccept(ws -> {
if (closed) ws.abort();
else socket = ws;
});
} catch (RuntimeException ignored) {
// The HTTPS fallback keeps working.
}
}
}
@@ -107,12 +107,12 @@ public record Settings(
);
}
/** Local opt-out for the central live map (the panel also has a per-server switch). */
public static boolean liveMapEnabled(Path path) {
/** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
public static boolean mapEnabled(Path path) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
Properties properties = new Properties();
properties.load(reader);
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true")));
} catch (IOException e) {
return true;
}
@@ -139,9 +139,33 @@ public record Settings(
essentials.enabled=true
economy.enabled=true
# Send world chunks and player positions to the panel's central Live Map.
# It only runs when Live Map is also enabled for this server in the panel.
livemap.enabled=true
# Essentials limits. Players can exceed max_homes with the permission scopenet.homes.<number>
# (or scopenet.homes.unlimited). Cooldowns are seconds, 0 = none; scopenet.cooldown.bypass skips them.
essentials.max_homes=5
essentials.home_name_max_length=32
essentials.max_warps=0
essentials.tpa_timeout_seconds=60
essentials.rtp_radius=2500
essentials.rtp_min_radius=0
essentials.rtp_attempts=15
essentials.cooldown.spawn=0
essentials.cooldown.home=0
essentials.cooldown.back=0
essentials.cooldown.tpa=0
essentials.cooldown.rtp=0
essentials.cooldown.warp=0
# Rewards from quests, achievements and rank milestones (items, permissions, groups, messages).
# allow_commands lets the panel run its own console commands as rewards: leave off unless you trust every panel admin.
rewards.enabled=true
rewards.allow_commands=false
rewards.permission_command=lp user {player} permission set {node} true
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
rewards.group_command=lp user {player} parent add {group}
# Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
# It only runs when the map is also enabled for this server in the panel.
map.enabled=true
""";
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
}
@@ -0,0 +1,212 @@
package net.scopenet.worldmap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static net.scopenet.worldmap.MapColors.*;
/**
* Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by
* name keywords, then falls back to a stable neutral colour so the same block always looks the same.
*/
public final class BlockColors {
private BlockColors() {}
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
private static final Set<String> CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane",
"tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot",
"moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air");
private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"};
private static final Set<String> WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass");
private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan",
"purple", "blue", "brown", "green", "red", "black"};
private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE,
BROWN, GREEN, RED, BLACK};
private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51};
private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"};
private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_",
"infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"};
private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign",
"_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"};
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
/** Water is special-cased by the renderer, which shades it by depth. */
public static final int WATER_COLOR = 0xFF3F76E4;
private static final Map<String, Integer> TEXTURES = loadTextures();
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
/**
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
*/
public static int of(String blockName) {
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
}
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
public static int mapColor(String blockName) {
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
}
private static int resolve(String full) {
int id = mapColor(full);
if (id == 0) return 0;
if (id == WATER) return WATER_COLOR;
String n = plain(full);
Integer exact = TEXTURES.get(n);
if (exact != null) return exact;
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (!changed) break;
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
Integer hit = TEXTURES.get(candidate);
if (hit != null) return hit;
}
}
return MapColors.base(id);
}
private static String plain(String full) {
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
}
private static Map<String, Integer> loadTextures() {
Map<String, Integer> out = new java.util.HashMap<>();
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
if (in == null) return out;
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
int comma = line.indexOf(',');
if (comma <= 0 || comma + 7 > line.length()) continue;
try {
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
} catch (NumberFormatException ignored) {
// a malformed row just falls back to the map colour table
}
}
} catch (java.io.IOException ignored) {
// no texture table: the map colour table covers everything
}
return out;
}
private static int classify(String full) {
String n = plain(full);
int c = lookup(n);
if (c >= 0) return c;
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (changed && (c = lookup(s)) >= 0) return c;
}
return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)];
}
/** The colour for a plain name, or -1 when nothing matches. */
private static int lookup(String n) {
if (CLEAR.contains(n)) return NONE;
for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE;
if (WATERY.contains(n)) return WATER;
if (n.equals("lava")) return FIRE;
for (int i = 0; i < DYES.length; i++) {
String d = DYES[i] + "_";
if (!n.startsWith(d)) continue;
String rest = n.substring(d.length());
if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i];
for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i];
}
if (n.equals("terracotta")) return ORANGE;
if (n.equals("grass_block") || n.equals("slime_block")) return GRASS;
if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE
: n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1;
if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE
: n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1;
if (n.contains("deepslate")) return DEEPSLATE;
if (n.contains("tuff")) return TERRACOTTA_GRAY;
if (n.contains("calcite")) return TERRACOTTA_WHITE;
if (n.contains("dripstone")) return TERRACOTTA_BROWN;
if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK;
if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER;
if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED;
if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN;
if (n.equals("glowstone")) return SAND;
if (n.equals("bedrock")) return STONE;
if (n.contains("red_sand")) return ORANGE;
if (n.contains("end_stone")) return SAND;
if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND;
if (n.contains("gravel")) return STONE;
if (n.contains("podzol")) return PODZOL;
if (n.contains("mycelium")) return PURPLE;
if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN;
if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT;
if (n.equals("clay")) return CLAY;
if (n.contains("snow")) return SNOW;
if (n.equals("ice") || n.endsWith("_ice")) return ICE;
if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM
: n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE;
if (n.equals("raw_iron_block")) return RAW_IRON;
if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD;
if (n.equals("raw_copper_block")) return ORANGE;
if (n.equals("diamond_block")) return DIAMOND;
if (n.equals("emerald_block")) return EMERALD;
if (n.equals("lapis_block")) return LAPIS;
if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE;
if (n.contains("amethyst")) return PURPLE;
if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ;
if (n.contains("andesite")) return STONE;
if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA;
if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND;
if (n.contains("prismarine")) return CYAN;
if (n.contains("_ore")) return STONE;
if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron")
|| n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL;
if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer")
|| n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE;
if (n.equals("hay_block")) return YELLOW;
if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW;
if (n.contains("melon")) return LIGHT_GREEN;
if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE;
if (n.equals("glow_lichen")) return GLOW_LICHEN;
if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN;
if (n.contains("red_mushroom")) return RED;
if (n.contains("brown_mushroom")) return DIRT;
if (n.equals("mushroom_stem")) return WOOL;
if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK;
if (n.equals("beacon")) return DIAMOND;
if (n.contains("cherry_leaves")) return PINK;
if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea")
|| n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip")
|| n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet")
|| n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony")
|| n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes")
|| n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush")
|| n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT;
if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD;
if (n.contains("mangrove")) return RED;
if (n.contains("spruce")) return PODZOL;
if (n.contains("birch")) return SAND;
if (n.contains("jungle")) return DIRT;
if (n.contains("acacia")) return ORANGE;
if (n.contains("cherry")) return TERRACOTTA_WHITE;
if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest")
|| n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom")
|| n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD;
if (n.contains("coal")) return BLACK;
if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL;
if (n.contains("wool") || n.contains("carpet")) return WOOL;
return -1;
}
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
}
@@ -0,0 +1,106 @@
package net.scopenet.worldmap;
import java.util.ArrayList;
import java.util.List;
/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */
public final class ChunkSampler {
private ChunkSampler() {}
public enum Kind { OVERWORLD, NETHER, END }
/** Per-column results, indexed {@code z * 16 + x}. */
public static final class Columns {
public final int[] color = new int[256];
public final int[] height = new int[256];
/** For water: how many blocks of water sit above the floor. */
public final int[] depth = new int[256];
}
private record Section(int y, int[] colors, long[] data, int bits) {
int colorAt(int x, int y, int z) {
if (data == null) return colors[0];
int index = y << 8 | z << 4 | x;
int perLong = 64 / bits;
int word = index / perLong;
if (word >= data.length) return 0;
int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1));
return v < colors.length ? colors[v] : 0;
}
}
/** True if the chunk was fully generated and could be read (the columns are then filled in). */
public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) {
String status = chunk.string("Status", "minecraft:full");
if (!status.endsWith("full")) return false;
List<Section> sections = new ArrayList<>();
for (Object o : chunk.list("sections")) {
if (!(o instanceof Nbt.Compound s)) continue;
Nbt.Compound states = s.compound("block_states");
if (states == null) continue;
List<Object> palette = states.list("palette");
if (palette.isEmpty()) continue;
int[] colors = new int[palette.size()];
for (int i = 0; i < colors.length; i++) {
colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0;
}
long[] data = states.longs("data");
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1));
sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits));
}
if (sections.isEmpty()) return false;
sections.sort((a, b) -> Integer.compare(b.y(), a.y()));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int i = z * 16 + x;
out.color[i] = 0;
out.height[i] = 0;
out.depth[i] = 0;
if (kind == Kind.NETHER) column(sections, x, z, out, i, true);
else column(sections, x, z, out, i, false);
}
}
return true;
}
private static void column(List<Section> sections, int x, int z, Columns out, int i, boolean roofed) {
boolean pastRoof = !roofed;
int roofColor = 0, roofY = 0;
for (int si = 0; si < sections.size(); si++) {
Section sec = sections.get(si);
for (int ly = 15; ly >= 0; ly--) {
int worldY = sec.y() * 16 + ly;
if (roofed && worldY > 126) continue;
int c = sec.colorAt(x, ly, z);
if (!pastRoof) {
// Under the nether ceiling: skip solid rock until the first open air.
if (c == 0) pastRoof = true;
else if (roofColor == 0) { roofColor = c; roofY = worldY; }
continue;
}
if (c == 0) continue;
out.color[i] = c;
out.height[i] = worldY;
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
return;
}
}
if (roofed && roofColor != 0) { // a solid column: show the ceiling instead
out.color[i] = roofColor;
out.height[i] = roofY;
}
}
private static int waterDepth(List<Section> sections, int si, int ly, int x, int z) {
int depth = 1;
for (int s = si; s < sections.size(); s++) {
Section sec = sections.get(s);
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
if (++depth >= 64) return depth;
}
}
return depth;
}
}
@@ -0,0 +1,38 @@
package net.scopenet.worldmap;
/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */
public final class MapColors {
private MapColors() {}
public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10,
STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19,
PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29,
GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37,
TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52,
CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58,
DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61;
private static final int[] RGB = {
0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070,
0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99,
0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200,
0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C,
0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464,
0xD8AF93, 0x7FA796};
public static final int COUNT = RGB.length;
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
private static final int[] SHADE = {180, 220, 255, 135};
/** The colour's base RGB (opaque ARGB), or 0 for none. */
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
public static int argb(int id, int shade) {
if (id <= 0 || id >= COUNT) return 0;
int base = RGB[id], m = SHADE[shade & 3];
int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255;
return 0xFF000000 | r << 16 | g << 8 | b;
}
}
@@ -0,0 +1,80 @@
package net.scopenet.worldmap;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */
public final class Nbt {
private Nbt() {}
/** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */
public static final class Compound extends HashMap<String, Object> {
public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; }
@SuppressWarnings("unchecked")
public List<Object> list(String key) { return get(key) instanceof List<?> l ? (List<Object>) l : List.of(); }
public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; }
public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; }
public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; }
}
private static final int MAX_DEPTH = 64;
private static final int MAX_ARRAY = 8 * 1024 * 1024;
/** Reads one named root compound (the form chunks are stored in). */
public static Compound readRoot(DataInputStream in) throws IOException {
byte type = in.readByte();
if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type);
in.readUTF(); // root name, usually empty
return readCompound(in, 0);
}
private static Compound readCompound(DataInputStream in, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
Compound out = new Compound();
while (true) {
byte type = in.readByte();
if (type == 0) return out;
String name = in.readUTF();
out.put(name, readValue(in, type, depth + 1));
}
}
private static Object readValue(DataInputStream in, byte type, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
switch (type) {
case 1: return in.readByte();
case 2: return in.readShort();
case 3: return in.readInt();
case 4: return in.readLong();
case 5: return in.readFloat();
case 6: return in.readDouble();
case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; }
case 8: return in.readUTF();
case 9: {
byte elementType = in.readByte();
int n = length(in);
List<Object> list = new ArrayList<>(Math.min(n, 4096));
for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1));
return list;
}
case 10: return readCompound(in, depth);
case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; }
case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; }
default: throw new IOException("unknown NBT tag " + type);
}
}
private static int length(DataInputStream in) throws IOException {
int n = in.readInt();
if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n);
return n;
}
}
@@ -0,0 +1,146 @@
package net.scopenet.worldmap;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */
public final class Png {
private Png() {}
/**
* Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
*/
public static byte[] encode(int[] argb, int width, int height) {
argb = limitColors(argb);
Map<Integer, Integer> palette = new LinkedHashMap<>();
for (int c : argb) {
int key = (c >>> 24) == 0 ? 0 : c;
if (!palette.containsKey(key)) {
palette.put(key, palette.size());
if (palette.size() > 256) return rgba(argb, width, height);
}
}
int n = palette.size();
int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8;
int rowBytes = (width * depth + 7) / 8;
byte[] raw = new byte[height * (1 + rowBytes)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
int idx = palette.get((c >>> 24) == 0 ? 0 : c);
if (depth == 8) raw[p + x] = (byte) idx;
else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8));
}
p += rowBytes;
}
byte[] plte = new byte[n * 3];
List<Integer> alpha = new ArrayList<>();
for (var e : palette.entrySet()) {
int c = e.getKey(), i = e.getValue();
plte[i * 3] = (byte) (c >> 16);
plte[i * 3 + 1] = (byte) (c >> 8);
plte[i * 3 + 2] = (byte) c;
alpha.add(c == 0 ? 0 : c >>> 24);
}
int last = -1;
for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i;
byte[] trns = new byte[last + 1];
for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i);
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "PLTE", plte);
if (trns.length > 0) chunk(out, "tRNS", trns);
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
/**
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
*/
static int[] limitColors(int[] argb) {
Map<Integer, Integer> counts = new HashMap<>();
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
if (counts.size() <= 255) return argb;
List<Integer> keep = new ArrayList<>(counts.keySet());
keep.sort((a, b) -> counts.get(b) - counts.get(a));
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
Set<Integer> kept = new HashSet<>(top);
Map<Integer, Integer> remap = new HashMap<>();
int[] out = argb.clone();
for (int i = 0; i < out.length; i++) {
int c = out[i];
if ((c >>> 24) == 0 || kept.contains(c)) continue;
out[i] = remap.computeIfAbsent(c, k -> {
int best = top.get(0), bestD = Integer.MAX_VALUE;
for (int q : top) {
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
if (d < bestD) { bestD = d; best = q; }
}
return best;
});
}
return out;
}
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
private static byte[] deflate(byte[] raw) {
Deflater deflater = new Deflater(9);
deflater.setInput(raw);
deflater.finish();
ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64);
byte[] buf = new byte[16384];
while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf));
deflater.end();
return z.toByteArray();
}
private static byte[] rgba(int[] argb, int width, int height) {
byte[] raw = new byte[height * (1 + width * 4)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
raw[p++] = (byte) (c >> 16);
raw[p++] = (byte) (c >> 8);
raw[p++] = (byte) c;
raw[p++] = (byte) (c >>> 24);
}
}
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
private static void chunk(ByteArrayOutputStream out, String type, byte[] data) {
out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array());
byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
out.writeBytes(t);
out.writeBytes(data);
CRC32 crc = new CRC32();
crc.update(t);
crc.update(data);
out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array());
}
}
@@ -0,0 +1,84 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
/**
* Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running:
* a chunk caught mid-write simply fails to read and is retried on the next pass.
*/
public final class RegionFile implements AutoCloseable {
private static final int SECTOR = 4096;
private final FileChannel channel;
private final int[] locations = new int[1024];
private final int[] timestamps = new int[1024];
public RegionFile(Path file) throws IOException {
channel = FileChannel.open(file, StandardOpenOption.READ);
try {
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (channel.read(header) < 0) break;
header.flip();
if (header.remaining() == 2 * SECTOR) {
header.asIntBuffer().get(locations, 0, 1024);
header.position(SECTOR);
header.asIntBuffer().get(timestamps, 0, 1024);
}
} catch (IOException | RuntimeException e) {
channel.close();
throw e;
}
}
public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; }
/** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */
public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; }
/**
* The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read.
* Region files written with LZ4 (a server option since 1.20.5) are skipped.
*/
public Nbt.Compound read(int index) {
int loc = locations[index];
if (loc == 0) return null;
long offset = (long) (loc >>> 8) * SECTOR;
int sectors = loc & 0xFF;
if (offset < 2 * SECTOR || sectors == 0) return null;
try {
ByteBuffer head = ByteBuffer.allocate(5);
if (read(head, offset) < 5) return null;
head.flip();
int length = head.getInt();
int type = head.get() & 0xFF;
if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file
ByteBuffer body = ByteBuffer.allocate(length - 1);
if (read(body, offset + 5) < length - 1) return null;
InputStream raw = new ByteArrayInputStream(body.array());
InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw;
try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) {
return Nbt.readRoot(in);
}
} catch (IOException | RuntimeException e) {
return null;
}
}
private int read(ByteBuffer buffer, long position) throws IOException {
int total = 0;
while (buffer.hasRemaining()) {
int n = channel.read(buffer, position + total);
if (n < 0) break;
total += n;
}
return total;
}
@Override public void close() throws IOException { channel.close(); }
}
@@ -0,0 +1,80 @@
package net.scopenet.worldmap;
import java.io.IOException;
import java.nio.file.Path;
/**
* Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded
* by the slope towards the north, and water shaded by depth.
*/
public final class RegionRenderer {
private RegionRenderer() {}
public static final int TILE = 256;
private static final int SIDE = 512;
/** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */
public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException {
int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE];
ChunkSampler.Columns cols = new ChunkSampler.Columns();
boolean any = false;
try (RegionFile region = new RegionFile(file)) {
for (int i = 0; i < 1024; i++) {
if (region.timestamp(i) == 0) continue;
Nbt.Compound chunk = region.read(i);
if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue;
int cx = (i & 31) * 16, cz = (i >> 5) * 16;
for (int z = 0; z < 16; z++) {
int dst = (cz + z) * SIDE + cx;
System.arraycopy(cols.color, z * 16, colors, dst, 16);
System.arraycopy(cols.height, z * 16, heights, dst, 16);
System.arraycopy(cols.depth, z * 16, depths, dst, 16);
}
any = true;
}
}
if (!any) return null;
int[] pixels = shade(colors, heights, depths);
int[][] tiles = new int[4][TILE * TILE];
for (int ty = 0; ty < 2; ty++) {
for (int tx = 0; tx < 2; tx++) {
int[] tile = tiles[tx + ty * 2];
for (int row = 0; row < TILE; row++) {
System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE);
}
}
}
return tiles;
}
/**
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
* Colours are the blocks' own, so they stay in few steps and compress well.
*/
static int[] shade(int[] colors, int[] heights, int[] depths) {
int[] out = new int[SIDE * SIDE];
for (int z = 0; z < SIDE; z++) {
for (int x = 0; x < SIDE; x++) {
int i = z * SIDE + x;
int c = colors[i];
if (c == 0) continue;
int percent;
if (c == BlockColors.WATER_COLOR) {
percent = 108 - Math.min(depths[i], 14) * 4;
} else {
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
int diff = heights[i] - north;
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
}
out[i] = scale(c, percent);
}
}
return out;
}
static int scale(int argb, int percent) {
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
b = Math.min(255, (argb & 0xFF) * percent / 100);
return 0xFF000000 | r << 16 | g << 8 | b;
}
}
@@ -0,0 +1,125 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per
* block; every level up halves the resolution, down to {@code maxZoom}.
*/
public final class TileStore {
public record Key(String dim, int zoom, int x, int y) {
@Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; }
public static Key parse(String s) {
String[] p = s.split("/");
return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]));
}
}
private static final int SIZE = RegionRenderer.TILE;
private static final int PIXELS = SIZE * SIZE;
private final Path root;
private final Map<Key, int[]> memory = new LinkedHashMap<>(64, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<Key, int[]> e) { return size() > 96; }
};
public TileStore(Path root) { this.root = root; }
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
/** The tile's pixels, or null if it was never rendered. */
public synchronized int[] load(Key k) {
int[] cached = memory.get(k);
if (cached != null) return cached;
Path f = file(k);
if (!Files.isRegularFile(f)) return null;
try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
byte[] raw = in.readAllBytes();
if (raw.length != PIXELS * 4) return null;
int[] pixels = new int[PIXELS];
ByteBuffer.wrap(raw).asIntBuffer().get(pixels);
memory.put(k, pixels);
return pixels;
} catch (IOException e) {
return null;
}
}
public synchronized void save(Key k, int[] pixels) throws IOException {
ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4);
raw.asIntBuffer().put(pixels);
Path f = file(k);
Files.createDirectories(f.getParent());
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) {
out.write(raw.array());
}
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
memory.put(k, pixels);
}
public synchronized void clear() throws IOException {
memory.clear();
if (!Files.isDirectory(root)) return;
try (var walk = Files.walk(root)) {
for (Path p : (Iterable<Path>) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p);
}
}
public static boolean blank(int[] pixels) {
for (int p : pixels) if ((p >>> 24) != 0) return false;
return true;
}
/** Stores a freshly rendered tile and returns true if it differs from what was there. */
public boolean update(Key k, int[] pixels) throws IOException {
int[] old = load(k);
if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false;
save(k, pixels);
return true;
}
/**
* Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null.
*/
public Key rebuildParent(Key child) throws IOException {
int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1;
int[] out = new int[PIXELS];
for (int qy = 0; qy < 2; qy++) {
for (int qx = 0; qx < 2; qx++) {
int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy));
if (src == null) continue;
int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2);
for (int y = 0; y < SIZE / 2; y++) {
for (int x = 0; x < SIZE / 2; x++) {
out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1],
src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]);
}
}
}
}
Key parent = new Key(child.dim(), pz, px, py);
return update(parent, out) ? parent : null;
}
/**
* The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps
* zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well.
*/
static int average(int a, int b, int c, int d) {
int[] px = {a, b, c, d};
int best = 0, bestCount = 0;
for (int i = 0; i < 4; i++) {
if ((px[i] >>> 24) == 0) continue;
int count = 0;
for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++;
if (count > bestCount) { best = px[i]; bestCount = count; }
}
return best;
}
}
@@ -0,0 +1,400 @@
package net.scopenet.worldmap;
import com.google.gson.*;
import net.scopenet.integration.PanelClient;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
/**
* SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one
* region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along.
* Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}.
*/
public final class WorldMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private record Dim(String id, String dir, ChunkSampler.Kind kind) {}
private static final Dim[] DIMS = {
new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD),
new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER),
new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)};
private static final int BATCH_BYTES = 3 * 1024 * 1024;
private static final long RERENDER_GAP_MS = 90_000;
private static final long COARSE_UPLOAD_GAP_MS = 60_000;
private static final class State {
long epoch = -1;
/** "dim/x.z" -> file mtime (ms) at the time it was rendered. */
Map<String, Long> regions = new HashMap<>();
/** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */
Set<String> dirty = new HashSet<>();
}
private final PanelClient client;
private final Path worldRoot;
private final Path cacheDir;
private final Consumer<String> log;
private final Gson gson = new Gson();
private final TileStore store;
private final Object wake = new Object();
private final Thread thread;
private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-map-live");
t.setDaemon(true);
return t;
});
private State state = new State();
private final Map<String, Long> renderedAt = new HashMap<>();
private volatile boolean closed;
private volatile boolean enabled;
private volatile boolean wakeRequested;
private volatile int maxZoom = 6;
private volatile long playerIntervalMs = 2000;
private volatile List<Player> players = List.of();
private volatile java.util.function.Supplier<String> overlay;
private long configAt;
private long coarseUploadAt;
private boolean stateDirty;
private boolean warnedMissing;
private String lastOverlaySent;
private long overlaySentAt;
private String lastPlayersSent = "";
private long playersSentAt;
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
this.client = client;
this.worldRoot = worldRoot;
this.cacheDir = cacheDir;
this.log = log;
this.store = new TileStore(cacheDir.resolve("tiles"));
thread = new Thread(this::run, "scopenet-map");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
}
public void start() {
loadState();
thread.start();
live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) {
players = List.copyOf(next);
}
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
public void setOverlay(java.util.function.Supplier<String> source) {
overlay = source;
}
/** The world was saved: look for changed regions soon. */
public void requestScan() {
synchronized (wake) {
wakeRequested = true;
wake.notifyAll();
}
}
@Override public void close() {
closed = true;
live.shutdownNow();
thread.interrupt();
saveState();
}
// ---- main loop ----
private void run() {
while (!closed) {
long pause = 10_000;
try {
if (!checkConfig()) pause = 60_000;
else {
boolean worked = cycle();
pause = worked ? 150 : 10_000;
}
} catch (InterruptedException e) {
return;
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
pause = 30_000;
} catch (Exception e) {
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
pause = 60_000;
}
if (stateDirty) saveState();
try {
synchronized (wake) {
if (!wakeRequested) wake.wait(pause);
wakeRequested = false;
}
} catch (InterruptedException e) {
return;
}
}
}
private boolean checkConfig() throws IOException, InterruptedException {
long now = System.currentTimeMillis();
if (now < configAt) return enabled;
configAt = now + 60_000;
try {
JsonObject config = client.get("map/config");
boolean on = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt()));
if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong());
long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0;
if (on && epoch != state.epoch) {
if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again.");
store.clear();
state = new State();
state.epoch = epoch;
renderedAt.clear();
stateDirty = true;
}
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
enabled = on;
} catch (PanelClient.HttpFailure e) {
enabled = false; // an older panel without the map
}
return enabled;
}
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
private boolean cycle() throws IOException, InterruptedException {
if (!Files.isDirectory(worldRoot)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
}
return false;
}
long now = System.currentTimeMillis();
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
List<Stale> stale = new ArrayList<>();
List<Player> online = players;
for (Dim dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
for (Path file : stream) {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
continue;
}
long mtime = Files.getLastModifiedTime(file).toMillis();
String key = dim.dir() + "/" + rx + "." + rz;
Long done = state.regions.get(key);
if (done != null && mtime <= done) continue;
Long last = renderedAt.get(key);
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
if (Files.size(file) < 8192) continue;
double distance = Double.MAX_VALUE;
for (Player p : online) {
if (!dim.id().equals(p.dimension())) continue;
double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256);
distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
}
stale.add(new Stale(dim, file, key, mtime, rx, rz, distance));
}
}
}
boolean worked = false;
if (!stale.isEmpty()) {
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
Stale next = stale.get(0);
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
worked = true;
}
if (flushUploads(stale.size() <= 1)) worked = true;
return worked;
}
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
int[][] tiles;
try {
tiles = RegionRenderer.render(file, dim.kind());
} catch (IOException | RuntimeException e) {
// A region being written right now; try again after the gap.
renderedAt.put(key, System.currentTimeMillis());
return;
}
renderedAt.put(key, System.currentTimeMillis());
List<TileStore.Key> changed = new ArrayList<>();
if (tiles != null) {
for (int i = 0; i < 4; i++) {
TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1));
if (store.update(k, tiles[i])) {
changed.add(k);
state.dirty.add(k.toString());
}
}
}
// Rebuild the zoom pyramid above whatever changed.
List<TileStore.Key> level = changed;
for (int z = 0; z < maxZoom && !level.isEmpty(); z++) {
Set<Long> seen = new HashSet<>();
List<TileStore.Key> next = new ArrayList<>();
for (TileStore.Key child : level) {
long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL);
if (!seen.add(id)) continue;
TileStore.Key parent = store.rebuildParent(child);
if (parent != null) {
next.add(parent);
state.dirty.add(parent.toString());
}
}
level = next;
}
state.regions.put(key, mtime);
stateDirty = true;
}
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException {
if (state.dirty.isEmpty()) return false;
long now = System.currentTimeMillis();
boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS;
List<TileStore.Key> ready = new ArrayList<>();
for (String s : state.dirty) {
TileStore.Key k = TileStore.Key.parse(s);
if (k.zoom() == 0 || coarseNow) ready.add(k);
}
if (ready.isEmpty()) return false;
ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed()));
if (coarseNow) coarseUploadAt = now;
boolean sentAny = false;
int batches = 0;
int i = 0;
while (i < ready.size() && batches < 8 && !closed) {
String dimension = ready.get(i).dim();
ByteArrayOutputStream body = new ByteArrayOutputStream();
List<TileStore.Key> inBatch = new ArrayList<>();
while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) {
TileStore.Key k = ready.get(i++);
int[] pixels = store.load(k);
if (pixels == null) {
state.dirty.remove(k.toString());
continue;
}
byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE);
body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array());
body.writeBytes(png);
inBatch.add(k);
}
if (inBatch.isEmpty()) continue;
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
stateDirty = true;
sentAny = true;
batches++;
}
return sentAny;
}
private static String idOf(String dir) {
for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id();
return "minecraft:" + dir;
}
// ---- live data ----
private void sendLive() {
if (closed || !enabled) return;
try {
long now = System.currentTimeMillis();
List<Player> snapshot = players;
JsonArray list = new JsonArray();
for (Player p : snapshot) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", Math.round(p.x() * 10) / 10.0);
o.addProperty("y", Math.round(p.y() * 10) / 10.0);
o.addProperty("z", Math.round(p.z() * 10) / 10.0);
o.addProperty("yaw", Math.round(p.yaw()));
list.add(o);
}
String text = list.toString();
if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) {
JsonObject body = new JsonObject();
body.add("players", list);
client.post("map/players", body);
lastPlayersSent = text;
playersSentAt = now;
}
java.util.function.Supplier<String> source = overlay;
String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get();
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
lastOverlaySent = ov;
overlaySentAt = now;
}
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
} catch (Exception ignored) {
// the next tick retries
}
}
// ---- world layout ----
private Path regionDir(Dim dim) {
String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString();
Path parent = worldRoot.toAbsolutePath().getParent();
List<Path> candidates = switch (dim.dir()) {
case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
worldRoot.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
worldRoot.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
// ---- state ----
private void loadState() {
try {
Path f = cacheDir.resolve("state.json");
if (Files.isRegularFile(f)) {
State s = gson.fromJson(Files.readString(f), State.class);
if (s != null && s.regions != null && s.dirty != null) state = s;
}
} catch (IOException | RuntimeException e) {
state = new State();
}
}
private void saveState() {
try {
Files.createDirectories(cacheDir);
Path f = cacheDir.resolve("state.json");
Path tmp = f.resolveSibling("state.json.tmp");
Files.writeString(tmp, gson.toJson(state));
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
stateDirty = false;
} catch (IOException e) {
// the cache is only an optimisation
}
}
}
@@ -0,0 +1,418 @@
stone,989898
granite,B6775E
polished_granite,BC7A63
diorite,E5E5E5
polished_diorite,EAEBED
andesite,A6A6A6
polished_andesite,A1A3A2
grass_block,618149
dirt,A37048
coarse_dirt,91633F
podzol,6F4913
cobblestone,9B9B9B
oak_planks,C69A53
spruce_planks,8B6231
birch_planks,EAD389
jungle_planks,C38656
acacia_planks,CD622C
cherry_planks,FFC2BB
dark_oak_planks,513112
mangrove_planks,8F3930
bamboo_planks,EBD051
bamboo_mosaic,E8CC4F
bedrock,686868
sand,FFEFB6
red_sand,E87011
gravel,A09A99
gold_ore,B1A17C
deepslate_gold_ore,8C7B5A
iron_ore,A69C93
deepslate_iron_ore,817871
coal_ore,808080
deepslate_coal_ore,5A5A5D
nether_gold_ore,8C3929
oak_log,B88F4E
spruce_log,845E2F
birch_log,EBD89C
jungle_log,B67F4A
acacia_log,B76235
cherry_log,E2A6A0
dark_oak_log,523414
mangrove_log,7C332A
mangrove_roots,5A4629
muddy_mangrove_roots,554533
bamboo_block,A9AC40
stripped_spruce_log,805E2F
stripped_birch_log,E9CE83
stripped_jungle_log,C98F57
stripped_acacia_log,CB642D
stripped_cherry_log,FFB5AC
stripped_dark_oak_log,4F3315
stripped_oak_log,C39952
stripped_mangrove_log,852B2B
stripped_bamboo_block,D9BE48
oak_leaves,4D7515
spruce_leaves,355C35
jungle_leaves,557E14
cherry_leaves,FFB8D3
dark_oak_leaves,517B16
mangrove_leaves,456913
azalea_leaves,6A8B2B
flowering_azalea_leaves,778742
sponge,EEEA49
wet_sponge,CFDD45
lapis_ore,7E8BAC
deepslate_lapis_ore,5B6A8C
lapis_block,1447AB
dispenser,959393
sandstone,FFF3BB
chiseled_sandstone,FFECAE
cut_sandstone,FFF1B3
note_block,6B422A
powered_rail,A78151
detector_rail,967C69
piston,BB975E
white_wool,FBFFFF
orange_wool,FF7200
magenta_wool,E741D9
light_blue_wool,2EC8FF
yellow_wool,FFC40E
lime_wool,7EE207
pink_wool,FF8BB1
gray_wool,4A5357
light_gray_wool,ADADA2
cyan_wool,07A6B1
purple_wool,8C20D2
blue_wool,3137C0
brown_wool,8B5026
green_wool,638515
red_wool,C31E17
black_wool,18191E
gold_block,FFD328
iron_block,FFFFFF
bricks,B76F5B
tnt,AD4034
bookshelf,8F6F3F
chiseled_bookshelf,D9AB5E
mossy_cobblestone,85906F
obsidian,110A1D
spawner,28364C
diamond_ore,91ACAB
deepslate_diamond_ore,628181
diamond_block,57FFF4
crafting_table,915228
farmland,AE764B
furnace,868585
oak_door,AB8246
iron_door,EDEBEB
redstone_ore,AB8080
deepslate_redstone_ore,7F5556
snow,F9FFFF
ice,85B0FF
cactus,629B28
clay,C0C9DA
jukebox,714A33
netherrack,762626
soul_sand,634938
soul_soil,5C4334
basalt,616269
polished_basalt,79777A
glowstone,D19A5A
nether_portal,6400EA
carved_pumpkin,B75D01
jack_o_lantern,FFAC27
cake,FFE1D8
repeater,C3BFBE
oak_trapdoor,97753A
spruce_trapdoor,7E5D31
birch_trapdoor,FDEBB8
jungle_trapdoor,B98053
acacia_trapdoor,BE602F
cherry_trapdoor,FFC2BB
dark_oak_trapdoor,5C3814
mangrove_trapdoor,862F29
bamboo_trapdoor,F2D857
stone_bricks,959395
mossy_stone_bricks,8B947E
cracked_stone_bricks,908F90
chiseled_stone_bricks,919091
packed_mud,AD7C57
mud_bricks,A77958
brown_mushroom_block,B68259
red_mushroom_block,F42220
mushroom_stem,F8EEDF
pumpkin,F28404
melon,83B014
mycelium,87767A
nether_bricks,36161D
enchanting_table,9C5461
cauldron,59585A
end_portal_frame,6B9273
end_stone,FBFFAD
dragon_egg,0E0A12
redstone_lamp,743C1B
emerald_ore,80A689
deepslate_emerald_ore,5B7F68
emerald_block,1CF859
command_block,D89C78
beacon,79FFF9
anvil,585858
chipped_anvil,585858
damaged_anvil,585858
comparator,CBC4C1
daylight_detector,9F8B6D
redstone_block,D61800
nether_quartz_ore,8F4844
hopper,5C5A5C
quartz_block,FFF8EF
chiseled_quartz_block,FFF9EE
quartz_pillar,FFF8EF
activator_rail,8C6654
dropper,959393
white_terracotta,FFD5BD
orange_terracotta,C45A1B
magenta_terracotta,B6627E
light_blue_terracotta,8680A7
yellow_terracotta,E39B15
lime_terracotta,7C8F36
pink_terracotta,C45353
gray_terracotta,463127
light_gray_terracotta,A57D71
cyan_terracotta,686F6F
purple_terracotta,904E64
blue_terracotta,58436F
brown_terracotta,5E3A25
green_terracotta,5C652D
red_terracotta,AE3E2A
black_terracotta,2D1910
slime_block,7CEA60
iron_trapdoor,F6F6F6
prismarine,6EC6AE
prismarine_bricks,6ED1BF
dark_prismarine,386F59
sea_lantern,CEF4E6
hay_block,C9A700
terracotta,B96A45
coal_block,141212
packed_ice,82AFFF
red_sandstone,DD6A10
chiseled_red_sandstone,DF680A
cut_red_sandstone,E76E0F
smooth_stone_slab,CCCCCC
smooth_stone,C1C1C1
bamboo_fence_gate,FBDF57
bamboo_fence,FBE05B
spruce_door,815E32
birch_door,FFF1C6
jungle_door,C78B5B
acacia_door,CC693A
cherry_door,FFBBB3
dark_oak_door,5D3B17
mangrove_door,89312E
bamboo_door,E9CE53
chorus_plant,714071
chorus_flower,B88EB8
purpur_block,CE92CE
purpur_pillar,D196D1
end_stone_bricks,F7FFB0
dirt_path,B59043
repeating_command_block,967DCF
chain_command_block,9BC4B1
nether_wart_block,8B0000
red_nether_bricks,540003
bone_block,FFFBD6
observer,787878
shulker_box,AA6FAA
white_shulker_box,F7FEFF
orange_shulker_box,FF6F00
magenta_shulker_box,D330C5
light_blue_shulker_box,23BDFF
yellow_shulker_box,FFBA03
lime_shulker_box,6ED206
pink_shulker_box,FF78A4
gray_shulker_box,42464C
light_gray_shulker_box,97978B
cyan_shulker_box,0892A5
purple_shulker_box,7615BE
blue_shulker_box,2629AB
brown_shulker_box,814B20
green_shulker_box,5D7A1C
red_shulker_box,AB1614
black_shulker_box,1E1E23
white_glazed_terracotta,DFFFF2
orange_glazed_terracotta,BCB266
magenta_glazed_terracotta,FE6AE7
light_blue_glazed_terracotta,62C2FE
yellow_glazed_terracotta,FFCC4D
lime_glazed_terracotta,C1F02E
pink_glazed_terracotta,FF9DBD
gray_glazed_terracotta,646D71
light_gray_glazed_terracotta,ACCACC
cyan_glazed_terracotta,358F98
purple_glazed_terracotta,7F2AB9
blue_glazed_terracotta,2C43AA
brown_glazed_terracotta,917F63
green_glazed_terracotta,8BAD47
red_glazed_terracotta,DD362E
black_glazed_terracotta,521F22
white_concrete,F6FEFF
orange_concrete,FF6E00
magenta_concrete,CE29C1
light_blue_concrete,139EF2
yellow_concrete,FFB300
lime_concrete,68CD08
pink_concrete,FF69A0
gray_concrete,41454A
light_gray_concrete,98988B
cyan_concrete,098FA6
purple_concrete,7214BE
blue_concrete,272AAE
brown_concrete,75431C
green_concrete,566F24
red_concrete,AD1717
black_concrete,090B12
white_concrete_powder,FCFFFF
orange_concrete_powder,FF8608
magenta_concrete_powder,EA55DF
light_blue_concrete_powder,45D3FF
yellow_concrete_powder,FFD624
lime_concrete_powder,8FE71C
pink_concrete_powder,FFA1C4
gray_concrete_powder,5C6266
light_gray_concrete_powder,BCBCB4
cyan_concrete_powder,1AB2C0
purple_concrete_powder,9931D8
blue_concrete_powder,474BCB
brown_concrete_powder,986036
green_concrete_powder,73912B
red_concrete_powder,CD312C
black_concrete_powder,1E1F26
dead_tube_coral_block,9F9590
dead_brain_coral_block,978E8A
dead_bubble_coral_block,A0958F
dead_fire_coral_block,A0958F
dead_horn_coral_block,A29993
tube_coral_block,2559FB
brain_coral_block,FD5EBB
bubble_coral_block,C90BC5
fire_coral_block,C71827
horn_coral_block,FFE939
sea_pickle,6D7627
blue_ice,649EFF
bamboo,EBD051
scaffolding,CF9A4B
loom,AD8E68
barrel,A3753C
smoker,686562
blast_furnace,616163
cartography_table,7E684C
fletching_table,F0D999
lectern,D3A258
smithing_table,444555
stonecutter,968F86
warped_stem,388586
stripped_warped_stem,349C97
warped_nylium,2A8B79
warped_wart_block,0C9194
crimson_stem,89334F
stripped_crimson_stem,943B5E
crimson_nylium,9F1717
shroomlight,FF8F35
crimson_planks,7B3351
warped_planks,2C7F78
crimson_trapdoor,7E3553
warped_trapdoor,2F9186
crimson_door,8B395B
warped_door,2A9A92
structure_block,6B586E
jigsaw,615263
target,FFB8A8
bee_nest,F6BD48
beehive,C0954F
honey_block,FFB41D
honeycomb_block,FF9A06
netherite_block,514A4C
ancient_debris,734C41
crying_obsidian,230549
respawn_anchor,520BB6
lodestone,B3B5B9
blackstone,332B32
polished_blackstone,403944
polished_blackstone_bricks,3A323C
cracked_polished_blackstone_bricks,362C34
chiseled_polished_blackstone,403944
gilded_blackstone,43312C
chiseled_nether_bricks,39191F
cracked_nether_bricks,31151A
quartz_bricks,FFF9EF
amethyst_block,9A69E9
budding_amethyst,9968E3
tuff,84857B
polished_tuff,757F78
chiseled_tuff,71786E
tuff_bricks,777C73
chiseled_tuff_bricks,868A80
calcite,FEFFFA
powder_snow,F9FFFF
sculk_sensor,005466
calibrated_sculk_sensor,165D7A
sculk,0D2028
sculk_vein,013A47
sculk_catalyst,0F252E
copper_block,EA7650
exposed_copper,C49375
weathered_copper,7DBB80
oxidized_copper,58C69D
copper_ore,979892
deepslate_copper_ore,70716C
oxidized_cut_copper,56BB96
weathered_cut_copper,80B17D
exposed_cut_copper,BC8F73
cut_copper,E97551
oxidized_chiseled_copper,5AC49D
weathered_chiseled_copper,78B782
exposed_chiseled_copper,BC8C72
chiseled_copper,E06E49
copper_door,EA7954
exposed_copper_door,C89079
oxidized_copper_door,59C39D
weathered_copper_door,80B77F
copper_trapdoor,E87550
exposed_copper_trapdoor,C49377
oxidized_copper_trapdoor,5AC49D
weathered_copper_trapdoor,7DBB80
copper_grate,E97650
exposed_copper_grate,C49377
weathered_copper_grate,7BB980
oxidized_copper_grate,58C49B
copper_bulb,BE5F37
exposed_copper_bulb,A57E66
weathered_copper_bulb,6B9A75
oxidized_copper_bulb,4CA182
dripstone_block,A37F6A
azalea,78972E
flowering_azalea,879446
moss_block,6A852E
big_dripleaf,83AC31
rooted_dirt,B07853
mud,494549
deepslate,6A6A6D
cobbled_deepslate,5E5E62
polished_deepslate,585859
deepslate_tiles,424243
deepslate_bricks,555557
chiseled_deepslate,424242
cracked_deepslate_bricks,4E4E4F
cracked_deepslate_tiles,3F3F3F
smooth_basalt,57575F
raw_iron_block,CBA07A
raw_copper_block,BC7955
raw_gold_block,FFBC1D
ochre_froglight,FFF9CD
verdant_froglight,EDFFEC
pearlescent_froglight,FFF9F8
reinforced_deepslate,61645F
crafter,897677
trial_spawner,2D455D
vault,3E5360
heavy_core,626873
1 stone 989898
2 granite B6775E
3 polished_granite BC7A63
4 diorite E5E5E5
5 polished_diorite EAEBED
6 andesite A6A6A6
7 polished_andesite A1A3A2
8 grass_block 618149
9 dirt A37048
10 coarse_dirt 91633F
11 podzol 6F4913
12 cobblestone 9B9B9B
13 oak_planks C69A53
14 spruce_planks 8B6231
15 birch_planks EAD389
16 jungle_planks C38656
17 acacia_planks CD622C
18 cherry_planks FFC2BB
19 dark_oak_planks 513112
20 mangrove_planks 8F3930
21 bamboo_planks EBD051
22 bamboo_mosaic E8CC4F
23 bedrock 686868
24 sand FFEFB6
25 red_sand E87011
26 gravel A09A99
27 gold_ore B1A17C
28 deepslate_gold_ore 8C7B5A
29 iron_ore A69C93
30 deepslate_iron_ore 817871
31 coal_ore 808080
32 deepslate_coal_ore 5A5A5D
33 nether_gold_ore 8C3929
34 oak_log B88F4E
35 spruce_log 845E2F
36 birch_log EBD89C
37 jungle_log B67F4A
38 acacia_log B76235
39 cherry_log E2A6A0
40 dark_oak_log 523414
41 mangrove_log 7C332A
42 mangrove_roots 5A4629
43 muddy_mangrove_roots 554533
44 bamboo_block A9AC40
45 stripped_spruce_log 805E2F
46 stripped_birch_log E9CE83
47 stripped_jungle_log C98F57
48 stripped_acacia_log CB642D
49 stripped_cherry_log FFB5AC
50 stripped_dark_oak_log 4F3315
51 stripped_oak_log C39952
52 stripped_mangrove_log 852B2B
53 stripped_bamboo_block D9BE48
54 oak_leaves 4D7515
55 spruce_leaves 355C35
56 jungle_leaves 557E14
57 cherry_leaves FFB8D3
58 dark_oak_leaves 517B16
59 mangrove_leaves 456913
60 azalea_leaves 6A8B2B
61 flowering_azalea_leaves 778742
62 sponge EEEA49
63 wet_sponge CFDD45
64 lapis_ore 7E8BAC
65 deepslate_lapis_ore 5B6A8C
66 lapis_block 1447AB
67 dispenser 959393
68 sandstone FFF3BB
69 chiseled_sandstone FFECAE
70 cut_sandstone FFF1B3
71 note_block 6B422A
72 powered_rail A78151
73 detector_rail 967C69
74 piston BB975E
75 white_wool FBFFFF
76 orange_wool FF7200
77 magenta_wool E741D9
78 light_blue_wool 2EC8FF
79 yellow_wool FFC40E
80 lime_wool 7EE207
81 pink_wool FF8BB1
82 gray_wool 4A5357
83 light_gray_wool ADADA2
84 cyan_wool 07A6B1
85 purple_wool 8C20D2
86 blue_wool 3137C0
87 brown_wool 8B5026
88 green_wool 638515
89 red_wool C31E17
90 black_wool 18191E
91 gold_block FFD328
92 iron_block FFFFFF
93 bricks B76F5B
94 tnt AD4034
95 bookshelf 8F6F3F
96 chiseled_bookshelf D9AB5E
97 mossy_cobblestone 85906F
98 obsidian 110A1D
99 spawner 28364C
100 diamond_ore 91ACAB
101 deepslate_diamond_ore 628181
102 diamond_block 57FFF4
103 crafting_table 915228
104 farmland AE764B
105 furnace 868585
106 oak_door AB8246
107 iron_door EDEBEB
108 redstone_ore AB8080
109 deepslate_redstone_ore 7F5556
110 snow F9FFFF
111 ice 85B0FF
112 cactus 629B28
113 clay C0C9DA
114 jukebox 714A33
115 netherrack 762626
116 soul_sand 634938
117 soul_soil 5C4334
118 basalt 616269
119 polished_basalt 79777A
120 glowstone D19A5A
121 nether_portal 6400EA
122 carved_pumpkin B75D01
123 jack_o_lantern FFAC27
124 cake FFE1D8
125 repeater C3BFBE
126 oak_trapdoor 97753A
127 spruce_trapdoor 7E5D31
128 birch_trapdoor FDEBB8
129 jungle_trapdoor B98053
130 acacia_trapdoor BE602F
131 cherry_trapdoor FFC2BB
132 dark_oak_trapdoor 5C3814
133 mangrove_trapdoor 862F29
134 bamboo_trapdoor F2D857
135 stone_bricks 959395
136 mossy_stone_bricks 8B947E
137 cracked_stone_bricks 908F90
138 chiseled_stone_bricks 919091
139 packed_mud AD7C57
140 mud_bricks A77958
141 brown_mushroom_block B68259
142 red_mushroom_block F42220
143 mushroom_stem F8EEDF
144 pumpkin F28404
145 melon 83B014
146 mycelium 87767A
147 nether_bricks 36161D
148 enchanting_table 9C5461
149 cauldron 59585A
150 end_portal_frame 6B9273
151 end_stone FBFFAD
152 dragon_egg 0E0A12
153 redstone_lamp 743C1B
154 emerald_ore 80A689
155 deepslate_emerald_ore 5B7F68
156 emerald_block 1CF859
157 command_block D89C78
158 beacon 79FFF9
159 anvil 585858
160 chipped_anvil 585858
161 damaged_anvil 585858
162 comparator CBC4C1
163 daylight_detector 9F8B6D
164 redstone_block D61800
165 nether_quartz_ore 8F4844
166 hopper 5C5A5C
167 quartz_block FFF8EF
168 chiseled_quartz_block FFF9EE
169 quartz_pillar FFF8EF
170 activator_rail 8C6654
171 dropper 959393
172 white_terracotta FFD5BD
173 orange_terracotta C45A1B
174 magenta_terracotta B6627E
175 light_blue_terracotta 8680A7
176 yellow_terracotta E39B15
177 lime_terracotta 7C8F36
178 pink_terracotta C45353
179 gray_terracotta 463127
180 light_gray_terracotta A57D71
181 cyan_terracotta 686F6F
182 purple_terracotta 904E64
183 blue_terracotta 58436F
184 brown_terracotta 5E3A25
185 green_terracotta 5C652D
186 red_terracotta AE3E2A
187 black_terracotta 2D1910
188 slime_block 7CEA60
189 iron_trapdoor F6F6F6
190 prismarine 6EC6AE
191 prismarine_bricks 6ED1BF
192 dark_prismarine 386F59
193 sea_lantern CEF4E6
194 hay_block C9A700
195 terracotta B96A45
196 coal_block 141212
197 packed_ice 82AFFF
198 red_sandstone DD6A10
199 chiseled_red_sandstone DF680A
200 cut_red_sandstone E76E0F
201 smooth_stone_slab CCCCCC
202 smooth_stone C1C1C1
203 bamboo_fence_gate FBDF57
204 bamboo_fence FBE05B
205 spruce_door 815E32
206 birch_door FFF1C6
207 jungle_door C78B5B
208 acacia_door CC693A
209 cherry_door FFBBB3
210 dark_oak_door 5D3B17
211 mangrove_door 89312E
212 bamboo_door E9CE53
213 chorus_plant 714071
214 chorus_flower B88EB8
215 purpur_block CE92CE
216 purpur_pillar D196D1
217 end_stone_bricks F7FFB0
218 dirt_path B59043
219 repeating_command_block 967DCF
220 chain_command_block 9BC4B1
221 nether_wart_block 8B0000
222 red_nether_bricks 540003
223 bone_block FFFBD6
224 observer 787878
225 shulker_box AA6FAA
226 white_shulker_box F7FEFF
227 orange_shulker_box FF6F00
228 magenta_shulker_box D330C5
229 light_blue_shulker_box 23BDFF
230 yellow_shulker_box FFBA03
231 lime_shulker_box 6ED206
232 pink_shulker_box FF78A4
233 gray_shulker_box 42464C
234 light_gray_shulker_box 97978B
235 cyan_shulker_box 0892A5
236 purple_shulker_box 7615BE
237 blue_shulker_box 2629AB
238 brown_shulker_box 814B20
239 green_shulker_box 5D7A1C
240 red_shulker_box AB1614
241 black_shulker_box 1E1E23
242 white_glazed_terracotta DFFFF2
243 orange_glazed_terracotta BCB266
244 magenta_glazed_terracotta FE6AE7
245 light_blue_glazed_terracotta 62C2FE
246 yellow_glazed_terracotta FFCC4D
247 lime_glazed_terracotta C1F02E
248 pink_glazed_terracotta FF9DBD
249 gray_glazed_terracotta 646D71
250 light_gray_glazed_terracotta ACCACC
251 cyan_glazed_terracotta 358F98
252 purple_glazed_terracotta 7F2AB9
253 blue_glazed_terracotta 2C43AA
254 brown_glazed_terracotta 917F63
255 green_glazed_terracotta 8BAD47
256 red_glazed_terracotta DD362E
257 black_glazed_terracotta 521F22
258 white_concrete F6FEFF
259 orange_concrete FF6E00
260 magenta_concrete CE29C1
261 light_blue_concrete 139EF2
262 yellow_concrete FFB300
263 lime_concrete 68CD08
264 pink_concrete FF69A0
265 gray_concrete 41454A
266 light_gray_concrete 98988B
267 cyan_concrete 098FA6
268 purple_concrete 7214BE
269 blue_concrete 272AAE
270 brown_concrete 75431C
271 green_concrete 566F24
272 red_concrete AD1717
273 black_concrete 090B12
274 white_concrete_powder FCFFFF
275 orange_concrete_powder FF8608
276 magenta_concrete_powder EA55DF
277 light_blue_concrete_powder 45D3FF
278 yellow_concrete_powder FFD624
279 lime_concrete_powder 8FE71C
280 pink_concrete_powder FFA1C4
281 gray_concrete_powder 5C6266
282 light_gray_concrete_powder BCBCB4
283 cyan_concrete_powder 1AB2C0
284 purple_concrete_powder 9931D8
285 blue_concrete_powder 474BCB
286 brown_concrete_powder 986036
287 green_concrete_powder 73912B
288 red_concrete_powder CD312C
289 black_concrete_powder 1E1F26
290 dead_tube_coral_block 9F9590
291 dead_brain_coral_block 978E8A
292 dead_bubble_coral_block A0958F
293 dead_fire_coral_block A0958F
294 dead_horn_coral_block A29993
295 tube_coral_block 2559FB
296 brain_coral_block FD5EBB
297 bubble_coral_block C90BC5
298 fire_coral_block C71827
299 horn_coral_block FFE939
300 sea_pickle 6D7627
301 blue_ice 649EFF
302 bamboo EBD051
303 scaffolding CF9A4B
304 loom AD8E68
305 barrel A3753C
306 smoker 686562
307 blast_furnace 616163
308 cartography_table 7E684C
309 fletching_table F0D999
310 lectern D3A258
311 smithing_table 444555
312 stonecutter 968F86
313 warped_stem 388586
314 stripped_warped_stem 349C97
315 warped_nylium 2A8B79
316 warped_wart_block 0C9194
317 crimson_stem 89334F
318 stripped_crimson_stem 943B5E
319 crimson_nylium 9F1717
320 shroomlight FF8F35
321 crimson_planks 7B3351
322 warped_planks 2C7F78
323 crimson_trapdoor 7E3553
324 warped_trapdoor 2F9186
325 crimson_door 8B395B
326 warped_door 2A9A92
327 structure_block 6B586E
328 jigsaw 615263
329 target FFB8A8
330 bee_nest F6BD48
331 beehive C0954F
332 honey_block FFB41D
333 honeycomb_block FF9A06
334 netherite_block 514A4C
335 ancient_debris 734C41
336 crying_obsidian 230549
337 respawn_anchor 520BB6
338 lodestone B3B5B9
339 blackstone 332B32
340 polished_blackstone 403944
341 polished_blackstone_bricks 3A323C
342 cracked_polished_blackstone_bricks 362C34
343 chiseled_polished_blackstone 403944
344 gilded_blackstone 43312C
345 chiseled_nether_bricks 39191F
346 cracked_nether_bricks 31151A
347 quartz_bricks FFF9EF
348 amethyst_block 9A69E9
349 budding_amethyst 9968E3
350 tuff 84857B
351 polished_tuff 757F78
352 chiseled_tuff 71786E
353 tuff_bricks 777C73
354 chiseled_tuff_bricks 868A80
355 calcite FEFFFA
356 powder_snow F9FFFF
357 sculk_sensor 005466
358 calibrated_sculk_sensor 165D7A
359 sculk 0D2028
360 sculk_vein 013A47
361 sculk_catalyst 0F252E
362 copper_block EA7650
363 exposed_copper C49375
364 weathered_copper 7DBB80
365 oxidized_copper 58C69D
366 copper_ore 979892
367 deepslate_copper_ore 70716C
368 oxidized_cut_copper 56BB96
369 weathered_cut_copper 80B17D
370 exposed_cut_copper BC8F73
371 cut_copper E97551
372 oxidized_chiseled_copper 5AC49D
373 weathered_chiseled_copper 78B782
374 exposed_chiseled_copper BC8C72
375 chiseled_copper E06E49
376 copper_door EA7954
377 exposed_copper_door C89079
378 oxidized_copper_door 59C39D
379 weathered_copper_door 80B77F
380 copper_trapdoor E87550
381 exposed_copper_trapdoor C49377
382 oxidized_copper_trapdoor 5AC49D
383 weathered_copper_trapdoor 7DBB80
384 copper_grate E97650
385 exposed_copper_grate C49377
386 weathered_copper_grate 7BB980
387 oxidized_copper_grate 58C49B
388 copper_bulb BE5F37
389 exposed_copper_bulb A57E66
390 weathered_copper_bulb 6B9A75
391 oxidized_copper_bulb 4CA182
392 dripstone_block A37F6A
393 azalea 78972E
394 flowering_azalea 879446
395 moss_block 6A852E
396 big_dripleaf 83AC31
397 rooted_dirt B07853
398 mud 494549
399 deepslate 6A6A6D
400 cobbled_deepslate 5E5E62
401 polished_deepslate 585859
402 deepslate_tiles 424243
403 deepslate_bricks 555557
404 chiseled_deepslate 424242
405 cracked_deepslate_bricks 4E4E4F
406 cracked_deepslate_tiles 3F3F3F
407 smooth_basalt 57575F
408 raw_iron_block CBA07A
409 raw_copper_block BC7955
410 raw_gold_block FFBC1D
411 ochre_froglight FFF9CD
412 verdant_froglight EDFFEC
413 pearlescent_froglight FFF9F8
414 reinforced_deepslate 61645F
415 crafter 897677
416 trial_spawner 2D455D
417 vault 3E5360
418 heavy_core 626873
@@ -22,7 +22,7 @@ class EssentialsServiceTest {
public Pos pos() { return pos; }
public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return true; }
public boolean hasPermission(String n) { return !n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass"); }
public long playtimeSeconds() { return 0; }
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
public void clearHeld() { }
@@ -17,6 +17,8 @@ final class Kit {
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>();
final Set<String> denied = new HashSet<>();
/** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */
final Set<String> granted = new HashSet<>();
Item held;
final List<Item> inventory = new ArrayList<>();
long playtime = 3700;
@@ -26,7 +28,7 @@ final class Kit {
public Pos pos() { return pos; }
public void send(String m) { inbox.add(Format.plain(m)); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return !denied.contains(n); }
public boolean hasPermission(String n) { return granted.contains(n) || (!n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass") && !denied.contains(n)); }
public long playtimeSeconds() { return playtime; }
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
public void clearHeld() { held = null; }
@@ -0,0 +1,76 @@
package net.scopenet.core;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class LimitsTest {
@Test void permissionTiersRaiseALimit() {
assertEquals(5, Limits.tier(n -> false, "scopenet.homes.", 5, 500));
assertEquals(10, Limits.tier(Set.of("scopenet.homes.10")::contains, "scopenet.homes.", 5, 500));
assertEquals(25, Limits.tier(Set.of("scopenet.homes.10", "scopenet.homes.25", "scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "highest wins");
assertEquals(5, Limits.tier(Set.of("scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "a lower tier never reduces the base");
assertEquals(Integer.MAX_VALUE, Limits.tier(Set.of("scopenet.homes.unlimited")::contains, "scopenet.homes.", 5, 500));
assertEquals("unlimited", Limits.show(Integer.MAX_VALUE));
}
@Test void homesFollowConfigAndPermissions(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(2, 2500, 60_000, 15, 0, 1, 8, Map.of());
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, kit.env.rng);
svc.setHome(steve, new String[]{"a"});
svc.setHome(steve, new String[]{"b"});
svc.setHome(steve, new String[]{"c"});
assertTrue(steve.last().contains("limit of 2"));
steve.granted.add("scopenet.homes.4");
svc.setHome(steve, new String[]{"c"});
svc.setHome(steve, new String[]{"d"});
svc.setHome(steve, new String[]{"e"});
assertTrue(steve.last().contains("limit of 4"), steve.last());
svc.setHome(steve, new String[]{"waytoolongname"});
assertTrue(steve.last().contains("at most 8"), steve.last());
svc.setWarp(steve, "spawn");
svc.setWarp(steve, "pvp");
assertTrue(steve.last().contains("already has 1 warps"), steve.last());
}
@Test void teleportCommandsCoolDownOnlyAfterTeleporting(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(5, 2500, 60_000, 15, 0, 0, 32, Map.of("home", 30, "warp", 10));
CommandSet set = new CommandSet(kit.env, cfg);
set.essentials.setHome(steve, new String[]{"base"});
set.run("home", steve, "nope"); // unknown home: nothing happened, no cooldown
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), steve.last());
set.run("home", steve, "base");
assertTrue(steve.last().contains("Please wait 30s"), steve.last());
kit.now.addAndGet(31_000);
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), steve.last());
set.run("warp", steve, "missing");
set.run("warp", steve, "missing");
assertFalse(steve.last().contains("Please wait"), "a failed warp costs nothing");
steve.granted.add("scopenet.cooldown.bypass");
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), "bypass skips cooldowns");
}
@Test void rtpKeepsAwayFromTheCentre(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(5, 400, 60_000, 50, 300, 0, 32, Map.of());
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, new java.util.Random(7));
for (int i = 0; i < 20; i++) {
svc.rtp(steve);
int x = (int) steve.pos.x(), z = (int) steve.pos.z();
assertTrue(Math.max(Math.abs(x), Math.abs(z)) >= 300, "landed at " + x + "," + z);
}
}
}
@@ -0,0 +1,29 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.core.map.*;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MapOverlayTest {
@Test void overlayCarriesShapesAndPlainText() {
var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16)));
var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron <Wolves>", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8,
"<div class=\"scopenet-detail\"><strong>Iron &lt;Wolves&gt;</strong> <span>[IRN]</span><br>1 claimed chunk in this area</div>");
var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6,
"<div class=\"scopenet-detail\"><strong>Warp</strong> a<br><code>/warp a</code></div>");
JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of()));
JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject();
assertEquals("#3366cc", c.get("color").getAsString());
assertEquals(4, c.getAsJsonArray("outer").size());
assertEquals("Iron <Wolves> [IRN]", c.getAsJsonArray("lines").get(0).getAsString());
assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString());
JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject();
assertEquals("warp", p.get("kind").getAsString());
assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString());
assertFalse(json.toString().contains("<div"), "no markup reaches the viewer");
}
}
@@ -0,0 +1,328 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.scopenet.core.map.*;
import net.scopenet.integration.ClaimIndex;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class MapTest {
@TempDir Path dir;
static Set<Long> chunks(String... rows) {
Set<Long> set = new HashSet<>();
for (int z = 0; z < rows.length; z++) for (int x = 0; x < rows[z].length(); x++) if (rows[z].charAt(x) == '#') set.add(ChunkRegions.key(x, z));
return set;
}
// ---- outlines --------------------------------------------------------------------
@Test void singleChunkIsASixteenBlockSquare() {
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("#"));
assertEquals(1, r.size());
assertEquals(4, r.get(0).outer().points().size());
assertEquals(256, r.get(0).outer().area(), 0.001);
assertEquals(8, r.get(0).labelX());
assertEquals(8, r.get(0).labelZ());
}
@Test void straightEdgesAreMergedIntoOneSide() {
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("####", "####"));
assertEquals(4, r.get(0).outer().points().size(), "a 4x2 block is still just a rectangle");
assertEquals(8 * 256, r.get(0).outer().area(), 0.001);
}
@Test void lShapeHasSixCorners() {
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("#.", "#.", "##"));
assertEquals(1, r.size());
assertEquals(6, r.get(0).outer().points().size());
assertEquals(4 * 256, r.get(0).outer().area(), 0.001);
assertEquals(4, r.get(0).chunks());
}
@Test void aDonutKeepsItsHole() {
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("###", "#.#", "###"));
assertEquals(1, r.size());
assertEquals(9 * 256, r.get(0).outer().area(), 0.001);
assertEquals(1, r.get(0).holes().size());
assertEquals(-256, r.get(0).holes().get(0).area(), 0.001);
assertEquals(8, r.get(0).chunks());
assertTrue(r.get(0).holes().get(0).contains(24, 24), "the hole is the middle chunk");
}
@Test void separateAreasAreSeparateRegionsAndDiagonalsDoNotJoin() {
List<ChunkRegions.Region> apart = ChunkRegions.regions(chunks("#.....#"));
assertEquals(2, apart.size());
List<ChunkRegions.Region> diagonal = ChunkRegions.regions(chunks("#.", ".#"));
assertEquals(2, diagonal.size(), "chunks that only touch at a corner are two areas");
for (ChunkRegions.Region region : diagonal) assertEquals(256, region.outer().area(), 0.001);
}
@Test void theLabelSitsInsideABentShape() {
// A "U": the average is in the gap, but the label must be on land.
Set<Long> u = chunks("#.#", "#.#", "###");
ChunkRegions.Region r = ChunkRegions.regions(u).get(0);
int cx = (int) Math.floor(r.labelX() / 16), cz = (int) Math.floor(r.labelZ() / 16);
assertTrue(u.contains(ChunkRegions.key(cx, cz)));
}
@Test void negativeCoordinatesWork() {
Set<Long> set = new HashSet<>(List.of(ChunkRegions.key(-3, -2), ChunkRegions.key(-2, -2), ChunkRegions.key(-3, -1)));
ChunkRegions.Region r = ChunkRegions.regions(set).get(0);
assertEquals(3 * 256, r.outer().area(), 0.001);
assertEquals(-48, r.outer().points().stream().mapToDouble(ChunkRegions.Point::x).min().getAsDouble());
assertEquals(-32, r.outer().points().stream().mapToDouble(ChunkRegions.Point::z).min().getAsDouble());
}
@Test void aBigRandomBlobStaysConsistent() {
Random rng = new Random(7);
Set<Long> blob = new HashSet<>();
for (int i = 0; i < 400; i++) blob.add(ChunkRegions.key(rng.nextInt(20), rng.nextInt(20)));
double land = 0;
for (ChunkRegions.Region r : ChunkRegions.regions(blob)) {
double holes = r.holes().stream().mapToDouble(h -> -h.area()).sum();
land += r.outer().area() - holes;
}
// Area inside all outlines (minus holes) is exactly the claimed land.
assertEquals(blob.size() * 256.0, land, 0.01);
}
// ---- the model -----------------------------------------------------------------------
static ClaimIndex.GuildClaims guild(String id, String name, String tag, String icon, Set<Long> c) { return new ClaimIndex.GuildClaims(id, name, tag, icon, c); }
static MapModel.Sources sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Collection<ShopPoints.Shop> shops, JsonArray market, boolean homes) {
Pos spawn = new Pos("minecraft:overworld", 0, 80, 0, 0, 0);
UUID owner = UUID.randomUUID();
return new MapModel.Sources(claims, Map.of("arena", new Pos("minecraft:overworld", 100, 65, -40, 0, 0)), List.of(spawn),
Map.of(owner, Map.of("base", new Pos("minecraft:overworld", 5, 70, 5, 0, 0))), Map.of("g1", new MapModel.NamedPos("[IRON] Iron Wolves", new Pos("minecraft:overworld", 32, 70, 32, 0, 0))),
shops, market, List.of(), "https://panel.example.com", homes, Map.of(owner, "Alex"));
}
@Test void claimsBecomeColouredRegionsWithNamesAndIcons() {
Map<String, List<ClaimIndex.GuildClaims>> claims = Map.of("minecraft:overworld", List.of(
guild("g1", "Iron <Wolves>", "IRON", "/textures/abc", chunks("##", "#.")),
guild("g2", "Void", "VOID", "https://cdn.example.com/v.png", chunks("#"))));
MapData data = MapModel.build(sources(claims, List.of(), new JsonArray(), false));
assertEquals(2, data.claims().size());
MapData.ClaimRegion iron = data.claims().stream().filter(c -> c.tag().equals("IRON")).findFirst().orElseThrow();
assertEquals("https://panel.example.com/textures/abc", iron.icon(), "panel paths become full addresses");
assertEquals("https://cdn.example.com/v.png", data.claims().stream().filter(c -> c.tag().equals("VOID")).findFirst().orElseThrow().icon());
assertFalse(iron.detail().contains("<Wolves>"), "names are HTML-escaped: " + iron.detail());
assertTrue(iron.detail().contains("&lt;Wolves&gt;"));
assertEquals(MapModel.guildColor("g1"), iron.color());
assertNotEquals(MapModel.guildColor("g1"), MapModel.guildColor("g2"));
assertTrue(iron.color() >= 0 && iron.color() <= 0xFFFFFF);
assertEquals("", MapModel.iconAddress("javascript:alert(1)", "https://p"), "only web addresses and panel paths are used as icons");
assertEquals("", MapModel.iconAddress(null, "https://p"));
}
@Test void pinsCoverSpawnWarpsGuildHomesAndShopsButHomesAreOptIn() {
List<ShopPoints.Shop> shops = List.of(new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0)),
new ShopPoints.Shop("Smith", "shop", new Pos("minecraft:overworld", 20, 64, 10, 0, 0)));
MapData off = MapModel.build(sources(Map.of(), shops, new JsonArray(), false));
Set<String> kinds = new TreeSet<>();
off.pins().forEach(p -> kinds.add(p.kind()));
assertEquals(new TreeSet<>(List.of("guild_home", "market", "shop", "spawn", "warp")), kinds, "homes stay private by default");
MapData on = MapModel.build(sources(Map.of(), shops, new JsonArray(), true));
MapData.Pin home = on.pins().stream().filter(p -> p.kind().equals("home")).findFirst().orElseThrow();
assertEquals("Alex's home", home.label());
assertEquals("[IRON] Iron Wolves", on.pins().stream().filter(p -> p.kind().equals("guild_home")).findFirst().orElseThrow().label());
}
@Test void marketPinsSummariseActivity() {
JsonArray market = JsonParser.parseString("[{\"id\":1,\"seller_name\":\"Mia\",\"seller_guild\":null,\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5},"
+ "{\"id\":2,\"seller_name\":\"X\",\"seller_guild\":\"IRON\",\"item_name\":\"<b>Hack</b>\",\"amount\":1,\"price\":5}]").getAsJsonArray();
ShopPoints.Shop shop = new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0));
MapData data = MapModel.build(sources(Map.of(), List.of(shop), market, false));
String detail = data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail();
assertTrue(detail.contains("2 listings"));
assertTrue(detail.contains("Diamond x3 &middot; 99.50"));
assertTrue(detail.contains("[IRON] guild"));
assertFalse(detail.contains("<b>Hack"), "listing names can't inject HTML");
assertTrue(MapModel.build(sources(Map.of(), List.of(shop), new JsonArray(), false)).pins().stream().anyMatch(p -> p.detail().contains("No listings")));
}
@Test void mapsAreMatchedToDimensions() {
assertEquals("minecraft:overworld", MapModel.dimensionOf("world", "World"));
assertEquals("minecraft:the_nether", MapModel.dimensionOf("world_nether", "Nether"));
assertEquals("minecraft:the_end", MapModel.dimensionOf("world_the_end", "The End"));
assertEquals("minecraft:overworld", MapModel.dimensionOf("legends", "Friends & Legends"), "'end' inside another word isn't the End");
}
@Test void playersJsonHasOnlyPublicFacts() {
JsonObject j = MapModel.playersJson("SMP", List.of(new MapData.OnlinePlayer("u-1", "Alex", 12, "Miner", 4, "Iron", "IRON", "leader", "VIP", 7300)), 123L);
JsonObject a = j.getAsJsonObject("players").getAsJsonObject("u-1");
assertEquals(12, a.get("level").getAsInt());
assertEquals("IRON", a.get("guildTag").getAsString());
assertFalse(a.has("balance"));
assertEquals("SMP", j.get("server").getAsString());
}
// ---- shop points ---------------------------------------------------------------------
@Test void shopPointsAreManagedByStaffAndSurviveARestart() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex");
ShopPoints points = new ShopPoints(dir.resolve("shops.json"));
Pos block = new Pos("minecraft:overworld", 10.4, 64.2, -3.7, 0, 0);
alex.denied.add(ShopPoints.NODE);
points.command(alex, new String[]{"add", "Bazaar"}, block);
assertTrue(alex.last().contains("permission"));
alex.denied.clear();
points.command(alex, new String[]{"add", "bad name"}, block);
assertTrue(alex.last().contains("Names are"));
points.command(alex, new String[]{"add", "Bazaar"}, block);
assertTrue(alex.last().contains("Shop point Bazaar set (market)"));
points.command(alex, new String[]{"add", "Twin", "shop"}, block);
assertTrue(alex.last().contains("already a shop point"));
points.command(alex, new String[]{"add", "Smith", "shop"}, new Pos("minecraft:overworld", 12, 64, -3, 0, 0));
assertEquals("Bazaar", points.at("minecraft:overworld", 10, 64, -4).orElseThrow().name(), "block coordinates floor correctly");
assertTrue(points.at("minecraft:the_nether", 10, 64, -4).isEmpty());
assertTrue(points.at("minecraft:overworld", 11, 64, -4).isEmpty());
ShopPoints reopened = new ShopPoints(dir.resolve("shops.json"));
assertEquals(2, reopened.all().size());
reopened.command(alex, new String[]{"remove", "bazaar"}, block);
assertEquals(1, reopened.all().size());
reopened.command(alex, new String[]{"remove", "ghost"}, block);
assertTrue(alex.last().contains("No shop point"));
reopened.command(alex, new String[]{"list"}, block);
assertTrue(alex.heard("Smith (shop)"));
}
@Test void marketPointCommandUsesTheBlockUnderFoot() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex");
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
alex.pos = new Pos("minecraft:overworld", 10.5, 65, 20.5, 0, 0);
set.run("market", alex, "point", "add", "Bazaar");
assertTrue(set.shops.at("minecraft:overworld", 10, 64, 20).isPresent());
}
// ---- actions from the map ------------------------------------------------------------
@Test void pollerCarriesOutMessagesNoticesAndTeleportRequests() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve");
List<String> tpas = new ArrayList<>();
ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name() + "->" + to.name()));
String json = "{\"actions\":[{\"id\":1,\"kind\":\"message\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"hello\"},"
+ "{\"id\":2,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"},"
+ "{\"id\":3,\"kind\":\"notify\",\"from_uuid\":\"\",\"from_name\":\"\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"§eAlex invited you\"},"
+ "{\"id\":4,\"kind\":\"message\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + UUID.randomUUID() + "\",\"text\":\"to nobody\"},"
+ "{\"id\":5,\"kind\":\"explode\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}";
kit.panel.on("actions/poll", json);
poller.tick();
assertTrue(steve.heard("[Map] Alex: hello"));
assertEquals(List.of("Alex->Steve"), tpas);
assertTrue(steve.heard("Alex invited you"));
assertEquals(2, steve.inbox.size(), "unknown actions and ones for absent players do nothing");
// It asks the panel at most every few seconds, and never overlaps itself.
kit.panel.calls.clear();
poller.tick();
poller.tick();
assertTrue(kit.panel.calls.isEmpty());
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
poller.tick();
assertEquals(1, kit.panel.calls.size());
}
@Test void teleportRequestsNeedThePermissionAndAPresentRequester() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve");
List<String> tpas = new ArrayList<>();
ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name()));
alex.denied.add("scopenet.command.tpa");
kit.panel.on("actions/poll", "{\"actions\":[{\"id\":1,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}");
poller.tick();
assertTrue(tpas.isEmpty());
assertTrue(alex.last().contains("do not have permission"));
kit.platform.online.remove(alex.id);
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
poller.tick();
assertTrue(steve.heard("has left"));
}
@Test void thePollerSurvivesAPanelOutage() {
Kit kit = new Kit(dir);
kit.platform.add("Alex");
ActionPoller poller = new ActionPoller(kit.env, (a, b) -> { });
kit.panel.handlers.put("actions/poll", b -> { throw new java.io.IOException("down"); });
poller.tick();
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
kit.panel.on("actions/poll", "{\"actions\":[]}");
poller.tick();
assertEquals(2, kit.panel.calls.size());
}
// ---- guild invitations in game -----------------------------------------------------------
@Test void guildInviteAcceptAndDeclineCommands() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex");
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
kit.panel.on("guilds/invite/send", "{\"ok\":true,\"player\":\"Steve\"}");
set.run("guild", alex, "invite", "steve");
assertTrue(alex.heard("Invitation sent to Steve"));
assertEquals("steve", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("target").getAsString());
set.run("guild", alex, "invite");
assertTrue(alex.last().startsWith("Usage"));
kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":true,\"guild\":\"Iron\",\"tag\":\"IRON\"}");
set.run("guild", alex, "accept", "IRON");
assertTrue(alex.heard("Welcome to [IRON] Iron"));
assertTrue(kit.panel.claimsRefreshed);
assertEquals("IRON", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("tag").getAsString());
kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":false,\"guild\":\"Iron\",\"tag\":\"IRON\"}");
set.run("guild", alex, "decline");
assertTrue(alex.last().contains("declined"));
alex.denied.add("scopenet.command.guild.invite");
set.run("guild", alex, "invite", "x");
assertTrue(alex.last().contains("permission to use /guild invite"));
}
@Test void mapServiceAssemblesEverythingOnTheServerSide() {
Kit kit = new Kit(dir);
Kit.Player alex = kit.platform.add("Alex");
kit.panel.on("player/info", LinkTest.INFO);
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
PlayerCache cache = new PlayerCache(kit.env);
cache.refresh(alex, null);
alex.pos = new Pos("minecraft:overworld", 100, 65, 100, 0, 0);
set.essentials.setWarp(alex, "Arena");
set.run("market", alex, "point", "add", "Bazaar");
kit.panel.on("economy/market", "[{\"id\":1,\"seller_name\":\"Mia\",\"item_name\":\"Diamond\",\"amount\":1,\"price\":5}]");
ClaimIndex claims = new ClaimIndex();
claims.apply(JsonParser.parseString("{\"revision\":\"1\",\"guilds\":[{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"icon_url\":\"/textures/x\"}],"
+ "\"claims\":[[\"minecraft:overworld\",0,0,0],[\"minecraft:overworld\",1,0,0]],\"members\":{\"g1\":[]}}").getAsJsonObject());
MapService service = new MapService(kit.env, claims, set.mapSource(), cache, "https://panel.example.com", false, "SMP");
service.tick();
MapData data = service.snapshot();
assertEquals(1, data.claims().size());
assertEquals("https://panel.example.com/textures/x", data.claims().get(0).icon());
assertEquals(2, data.claims().get(0).chunks());
Set<String> kinds = new TreeSet<>();
data.pins().forEach(p -> kinds.add(p.kind()));
assertEquals(new TreeSet<>(List.of("spawn", "warp", "market")), kinds);
assertTrue(data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail().contains("1 listing"));
assertEquals("Alex", data.players().get(0).name());
assertEquals(12, data.players().get(0).level());
assertEquals("IRON", service.playersJson().getAsJsonObject("players").getAsJsonObject(alex.id.toString()).get("guildTag").getAsString());
// Turning claims off hides them.
kit.env.features = new Features(true, true, true, true, false, true, "$");
assertTrue(service.snapshot().claims().isEmpty());
}
}
@@ -0,0 +1,88 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class RewardPollerTest {
/** A platform that records what was given and run. */
static final class Recording implements Platform {
final Kit.FakePlatform inner;
final List<String> given = new ArrayList<>(), ran = new ArrayList<>();
boolean knowsItems = true, knowsCommands = true;
Recording(Kit.FakePlatform inner) { this.inner = inner; }
public java.util.Optional<CorePlayer> player(java.util.UUID u) { return inner.player(u); }
public java.util.Optional<CorePlayer> playerByName(String n) { return inner.playerByName(n); }
public java.util.Collection<? extends CorePlayer> online() { return inner.online(); }
public java.util.Optional<Pos> safeSurface(String w, int x, int z) { return inner.safeSurface(w, x, z); }
public Pos worldSpawn(String w) { return inner.worldSpawn(w); }
public void runMain(Runnable r) { r.run(); }
public void runAsync(Runnable r) { r.run(); }
@Override public boolean giveItem(java.util.UUID p, String item, int amount) { if (knowsItems) given.add(item + "x" + amount); return knowsItems; }
@Override public boolean console(String c) { if (knowsCommands) ran.add(c); return knowsCommands; }
}
private static JsonObject delivery(long id, String uuid, String kind, String payload) {
JsonObject o = new JsonObject();
o.addProperty("id", id);
o.addProperty("uuid", uuid);
o.addProperty("kind", kind);
o.add("payload", JsonParser.parseString(payload));
return o;
}
@Test void rewardsAreCarriedOutAndReported(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
Recording platform = new Recording(kit.platform);
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
JsonObject[] ack = new JsonObject[1];
kit.panel.handlers.put("rewards/ack", b -> { ack[0] = b; return new JsonObject(); });
kit.panel.handlers.put("rewards/poll", b -> {
JsonArray list = new JsonArray();
String id = steve.id.toString();
list.add(delivery(1, id, "item", "{\"item\":\"minecraft:diamond\",\"amount\":3}"));
list.add(delivery(2, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":0}"));
list.add(delivery(3, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":10080}"));
list.add(delivery(4, id, "group", "{\"group\":\"vip\"}"));
list.add(delivery(5, id, "command", "{\"command\":\"give {player} cake 1\"}"));
list.add(delivery(6, id, "message", "{\"text\":\"Well done!\"}"));
JsonObject o = new JsonObject();
o.add("deliveries", list);
return o;
});
RewardPoller poller = new RewardPoller(env, RewardRules.defaults());
poller.tick();
assertEquals(List.of("minecraft:diamondx3"), platform.given);
assertEquals(List.of("lp user Steve permission set essentials.fly true", "lp user Steve permission settemp essentials.fly true 10080m",
"lp user Steve parent add vip"), platform.ran, "custom commands are off by default");
assertEquals(5, ack[0].getAsJsonArray("done").size());
assertEquals(1, ack[0].getAsJsonArray("failed").size());
assertTrue(ack[0].getAsJsonArray("failed").get(0).getAsJsonObject().get("error").getAsString().contains("allow-commands"));
assertTrue(steve.heard("Well done!"));
assertTrue(steve.heard("3x Diamond"));
}
@Test void commandsRunWhenTheOwnerAllowsThem(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
Recording platform = new Recording(kit.platform);
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
RewardRules r = RewardRules.defaults();
RewardPoller poller = new RewardPoller(env, new RewardRules(true, true, r.permissionCommand(), r.permissionDenyCommand(), r.permissionTempCommand(), r.groupCommand()));
assertNull(poller.apply(delivery(1, steve.id.toString(), "command", "{\"command\":\"give {player} cake 1\"}")));
assertEquals(List.of("give Steve cake 1"), platform.ran);
platform.knowsItems = false;
assertTrue(poller.apply(delivery(2, steve.id.toString(), "item", "{\"item\":\"mod:thing\",\"amount\":1}")).contains("unknown item"));
kit.platform.online.clear();
assertEquals("player left", poller.apply(delivery(3, steve.id.toString(), "message", "{\"text\":\"hi\"}")));
}
}
@@ -0,0 +1,230 @@
package net.scopenet.worldmap;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class MapRenderTest {
// ---- a tiny NBT writer for building chunks ----
static final class W {
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
final DataOutputStream out = new DataOutputStream(bytes);
W compound(String name) throws IOException { out.writeByte(10); out.writeUTF(name); return this; }
W end() throws IOException { out.writeByte(0); return this; }
W string(String name, String v) throws IOException { out.writeByte(8); out.writeUTF(name); out.writeUTF(v); return this; }
W i(String name, int v) throws IOException { out.writeByte(3); out.writeUTF(name); out.writeInt(v); return this; }
W b(String name, int v) throws IOException { out.writeByte(1); out.writeUTF(name); out.writeByte(v); return this; }
W list(String name, int type, int n) throws IOException { out.writeByte(9); out.writeUTF(name); out.writeByte(type); out.writeInt(n); return this; }
W longs(String name, long[] v) throws IOException {
out.writeByte(12); out.writeUTF(name); out.writeInt(v.length);
for (long l : v) out.writeLong(l);
return this;
}
}
/** A chunk with one section (y = sectionY) whose blocks come from {@code blockAt}(x,y,z) -> palette index. */
static byte[] chunk(int sectionY, List<String> palette, TriFunction blockAt) throws IOException {
W w = new W().compound("");
w.string("Status", "minecraft:full");
w.list("sections", 10, 1);
w.b("Y", sectionY);
w.compound("block_states");
w.list("palette", 10, palette.size());
for (String name : palette) w.string("Name", name).end();
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1));
int per = 64 / bits;
long[] data = new long[(4096 + per - 1) / per];
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
int idx = y << 8 | z << 4 | x;
data[idx / per] |= (long) blockAt.apply(x, y, z) << (idx % per * bits);
}
w.longs("data", data);
w.end(); // block_states
w.end(); // section
w.end(); // root
return w.bytes.toByteArray();
}
interface TriFunction { int apply(int x, int y, int z); }
/** Writes a region file with the given chunks at (cx, cz) -> NBT bytes, zlib compressed. */
static Path region(Path dir, Map<Integer, byte[]> chunks) throws IOException {
Path file = dir.resolve("r.0.0.mca");
ByteBuffer header = ByteBuffer.allocate(8192);
ByteArrayOutputStream body = new ByteArrayOutputStream();
int sector = 2;
for (var e : chunks.entrySet()) {
ByteArrayOutputStream z = new ByteArrayOutputStream();
try (DeflaterOutputStream d = new DeflaterOutputStream(z, new Deflater())) { d.write(e.getValue()); }
byte[] comp = z.toByteArray();
int length = comp.length + 1;
int sectors = (length + 4 + 4095) / 4096;
header.putInt(e.getKey() * 4, sector << 8 | sectors);
header.putInt(4096 + e.getKey() * 4, 1_700_000_000);
ByteBuffer rec = ByteBuffer.allocate(sectors * 4096);
rec.putInt(length).put((byte) 2).put(comp);
body.writeBytes(rec.array());
sector += sectors;
}
Files.createDirectories(dir);
try (OutputStream out = Files.newOutputStream(file)) {
out.write(header.array());
out.write(body.toByteArray());
}
return file;
}
@Test void blockNamesBecomeMapColours() {
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:air"));
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:glass"));
assertEquals(MapColors.GRASS, BlockColors.mapColor("minecraft:grass_block"));
assertEquals(MapColors.WATER, BlockColors.mapColor("minecraft:water"));
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:stone"));
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:mossy_cobblestone_stairs"));
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_planks"));
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_stairs"));
assertEquals(MapColors.PLANT, BlockColors.mapColor("minecraft:oak_leaves"));
assertEquals(MapColors.SAND, BlockColors.mapColor("minecraft:cut_sandstone_slab"));
assertEquals(MapColors.NETHER, BlockColors.mapColor("minecraft:netherrack"));
assertEquals(MapColors.RED, BlockColors.mapColor("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1);
assertEquals(MapColors.LIGHT_BLUE, BlockColors.mapColor("minecraft:light_blue_concrete"));
assertEquals(MapColors.DEEPSLATE, BlockColors.mapColor("minecraft:deepslate_iron_ore"));
assertEquals(MapColors.FIRE, BlockColors.mapColor("minecraft:lava"));
assertEquals(0, BlockColors.of("minecraft:air"));
assertEquals(BlockColors.WATER_COLOR, BlockColors.of("minecraft:water"));
int grass = BlockColors.of("minecraft:grass_block");
assertTrue((grass >> 8 & 0xFF) > (grass >> 16 & 0xFF) && (grass >> 8 & 0xFF) > (grass & 0xFF), "grass is green");
assertEquals(BlockColors.of("minecraft:oak_planks"), BlockColors.of("minecraft:oak_stairs"), "shapes share their material's colour");
assertTrue(BlockColors.of("create:brass_casing") != 0, "modded blocks still get a colour");
int modded = BlockColors.mapColor("create:brass_casing");
assertEquals(modded, BlockColors.mapColor("create:brass_casing"), "stable for unknown blocks");
assertTrue(modded > 0);
}
@Test void nbtAndRegionFilesRoundTrip(@TempDir Path dir) throws IOException {
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone"), (x, y, z) -> y < 8 ? 1 : 0);
Path file = region(dir, Map.of(RegionFile.index(3, 5), nbt));
try (RegionFile r = new RegionFile(file)) {
assertEquals(1_700_000_000, r.timestamp(RegionFile.index(3, 5)));
assertEquals(0, r.timestamp(RegionFile.index(0, 0)));
assertNull(r.read(RegionFile.index(0, 0)));
Nbt.Compound c = r.read(RegionFile.index(3, 5));
assertNotNull(c);
assertEquals("minecraft:full", c.string("Status", ""));
assertEquals(1, c.list("sections").size());
}
}
@Test void topSurfaceAndWaterDepthAreFound() throws IOException {
// y 0..9 stone, 10..12 water in the left half.
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone", "minecraft:water"),
(x, y, z) -> y < 6 ? 1 : (x < 8 && y < 9) ? 2 : y < 6 ? 1 : 0);
Nbt.Compound c = new Nbt.Compound();
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
ChunkSampler.Columns cols = new ChunkSampler.Columns();
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols));
assertEquals(BlockColors.of("minecraft:stone"), cols.color[15]);
assertEquals(4 * 16 + 5, cols.height[15]);
assertEquals(BlockColors.WATER_COLOR, cols.color[0]);
assertEquals(4 * 16 + 8, cols.height[0]);
assertEquals(3, cols.depth[0]);
}
@Test void theNetherIsShownBeneathItsCeiling() throws IOException {
// Section 7 covers y 112..127: bedrock up top (y 124..127 -> local 12..15), open air below, floor of netherrack at local y 3.
byte[] nbt = chunk(7, List.of("minecraft:air", "minecraft:bedrock", "minecraft:netherrack"),
(x, y, z) -> y >= 12 ? 1 : y <= 3 ? 2 : 0);
Nbt.Compound c;
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
ChunkSampler.Columns cols = new ChunkSampler.Columns();
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols));
assertEquals(BlockColors.of("minecraft:netherrack"), cols.color[0], "the floor, not the bedrock roof");
assertEquals(7 * 16 + 3, cols.height[0]);
}
@Test void regionsRenderIntoPositionedTiles(@TempDir Path dir) throws IOException {
byte[] flat = chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
// chunk (17, 2): second tile column (chunk x 16..31), first tile row
Path file = region(dir, Map.of(RegionFile.index(17, 2), flat));
int[][] tiles = RegionRenderer.render(file, ChunkSampler.Kind.OVERWORLD);
assertNotNull(tiles);
int[] right = tiles[1];
int px = (17 - 16) * 16 + 4, py = 2 * 16 + 4; // inside the chunk
assertEquals(0xFF, right[py * 256 + px] >>> 24);
int green = right[py * 256 + px];
assertTrue((green >> 8 & 0xFF) > (green >> 16 & 0xFF), "grass reads as green");
assertEquals(0, tiles[0][py * 256 + px], "other tiles stay empty");
assertEquals(0, right[0], "outside the chunk is transparent");
}
@Test void slopesAreShadedAgainstTheNorth() {
int n = 512;
int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n];
Arrays.fill(colors, BlockColors.of("minecraft:stone"));
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3);
int[] px = RegionRenderer.shade(colors, heights, depths);
int uphill = px[16 * n + 3]; // a step up from the north
int flat = px[17 * n + 3]; // same height as its north neighbour
assertTrue(brightness(uphill) >= brightness(flat));
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 200 - z / 8 * 3);
int[] down = RegionRenderer.shade(colors, heights, depths);
assertTrue(brightness(down[16 * n + 3]) <= brightness(down[17 * n + 3]));
}
private static int brightness(int argb) { return (argb >> 16 & 0xFF) + (argb >> 8 & 0xFF) + (argb & 0xFF); }
@Test void pngIsAValidImage() throws IOException {
int[] pixels = new int[256 * 256];
for (int i = 0; i < pixels.length; i++) pixels[i] = (i % 256 < 128) ? 0xFF336699 : 0;
byte[] png = Png.encode(pixels, 256, 256);
var image = ImageIO.read(new ByteArrayInputStream(png));
assertEquals(256, image.getWidth());
assertEquals(0xFF336699, image.getRGB(10, 10));
assertEquals(0, image.getRGB(200, 10) >>> 24);
assertTrue(png.length < 400, "a two-colour tile is tiny: " + png.length);
}
@Test void realisticTilesStaySmall(@TempDir Path dir) throws IOException {
byte[] hills = chunk(0, List.of("minecraft:air", "minecraft:grass_block", "minecraft:stone", "minecraft:water"),
(x, y, z) -> y < 3 + (x * 7 + z * 3) % 5 ? 1 : y < 9 && x > 8 ? 3 : 0);
Map<Integer, byte[]> chunks = new HashMap<>();
for (int i = 0; i < 256; i++) chunks.put(i, hills);
int[][] tiles = RegionRenderer.render(region(dir, chunks), ChunkSampler.Kind.OVERWORLD);
byte[] png = Png.encode(tiles[0], 256, 256);
byte[] rgba = Png.encode(tiles[0], 256, 256);
assertTrue(png.length < 20_000, "palette PNG size " + png.length);
var image = ImageIO.read(new ByteArrayInputStream(png));
for (int i = 0; i < 256 * 256; i += 997) assertEquals(tiles[0][i], image.getRGB(i % 256, i / 256), "pixel " + i);
}
@Test void zoomLevelsAreBuiltFromTheTilesBelow(@TempDir Path dir) throws IOException {
TileStore store = new TileStore(dir);
int[] red = new int[256 * 256];
Arrays.fill(red, 0xFFFF0000);
TileStore.Key k = new TileStore.Key("overworld", 0, 3, -2);
assertTrue(store.update(k, red));
assertFalse(store.update(k, red.clone()), "an identical tile is not a change");
TileStore.Key parent = store.rebuildParent(k);
assertEquals(new TileStore.Key("overworld", 1, 1, -1), parent);
int[] p = store.load(parent);
// child (3,-2) is the lower-right?? x=3 odd -> right, y=-2 even -> top: quadrant (1,0)
assertEquals(0xFFFF0000, p[10 * 256 + 200]);
assertEquals(0, p[10 * 256 + 10]);
assertEquals(parent, TileStore.Key.parse(parent.toString()));
// a fresh store sees the cached tile
assertArrayEquals(red, new TileStore(dir).load(k));
store.clear();
assertNull(new TileStore(dir).load(k));
}
}
@@ -0,0 +1,78 @@
package net.scopenet.worldmap;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
import net.scopenet.integration.PanelClient;
import net.scopenet.integration.Settings;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
/** A fake panel and a generated world: tiles, players and the overlay all arrive, and only once. */
class WorldMapSyncTest {
@Test void worldsAreRenderedAndUploaded(@TempDir Path dir) throws Exception {
byte[] flat = MapRenderTest.chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
MapRenderTest.region(dir.resolve("world/region"), Map.of(RegionFile.index(1, 1), flat, RegionFile.index(20, 3), flat));
List<String> tiles = new CopyOnWriteArrayList<>();
List<JsonObject> players = new CopyOnWriteArrayList<>();
List<JsonObject> overlays = new CopyOnWriteArrayList<>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/server/v1/map/config", ex -> reply(ex, "{\"enabled\":true,\"epoch\":3,\"max_zoom\":2,\"player_interval_ms\":500}"));
server.createContext("/api/server/v1/map/tiles", ex -> {
assertEquals("dim=minecraft:overworld", ex.getRequestURI().getRawQuery());
ByteBuffer body = ByteBuffer.wrap(ex.getRequestBody().readAllBytes());
while (body.hasRemaining()) {
int zoom = body.get() & 0xFF, x = body.getInt(), y = body.getInt(), len = body.getInt();
byte[] png = new byte[len];
body.get(png);
assertEquals((byte) 0x89, png[0]);
tiles.add(zoom + "/" + x + "/" + y);
}
reply(ex, "{\"stored\":1}");
});
server.createContext("/api/server/v1/map/players", ex -> { players.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
server.createContext("/api/server/v1/map/overlay", ex -> { overlays.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
server.start();
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
WorldMapSync sync = new WorldMapSync(client, dir.resolve("world"), dir.resolve("cache"), System.err::println);
sync.setOverlay(() -> "{\"claims\":[],\"pins\":[{\"id\":\"spawn\"}]}");
sync.offerPlayers(List.of(new WorldMapSync.Player("11111111-1111-1111-1111-111111111111", "Steve", "minecraft:overworld", 10.5, 64, 20, 90, 0)));
try {
sync.start();
long deadline = System.currentTimeMillis() + 20_000;
while ((tiles.size() < 4 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100);
// chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them
assertTrue(tiles.contains("0/0/0"), tiles.toString());
assertTrue(tiles.contains("0/1/0"), tiles.toString());
assertTrue(tiles.contains("1/0/0"), tiles.toString());
assertTrue(tiles.contains("2/0/0"), tiles.toString());
assertEquals("Steve", players.get(0).getAsJsonArray("players").get(0).getAsJsonObject().get("name").getAsString());
assertEquals("spawn", overlays.get(0).getAsJsonArray("pins").get(0).getAsJsonObject().get("id").getAsString());
int sent = tiles.size();
Thread.sleep(1500);
assertEquals(sent, tiles.size(), "an unchanged world isn't uploaded again");
} finally {
sync.close();
server.stop(0);
}
// the state survives a restart: a new sync with the same cache sends nothing
assertTrue(java.nio.file.Files.isRegularFile(dir.resolve("cache/state.json")));
}
private static void reply(com.sun.net.httpserver.HttpExchange ex, String json) throws java.io.IOException {
byte[] b = json.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, b.length);
ex.getResponseBody().write(b);
ex.close();
}
}
@@ -40,6 +40,8 @@ public final class ClientState {
public volatile List<ShopItem> shop = List.of();
public volatile Held held;
public volatile long marketAt, shopAt;
/** A window the server asked us to open ("market" or "shop"), taken once by the screen code. */
public volatile String pendingOpen;
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
public String[] handle(String json) {
@@ -94,6 +96,7 @@ public final class ClientState {
shopAt = System.currentTimeMillis();
}
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
case "open" -> pendingOpen = str(m, "screen");
default -> { }
}
return null;
@@ -23,7 +23,16 @@ public final class Link {
void register() {
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
String json = buf.readUtf(32767 * 3);
client.execute(() -> toast(state.handle(json)));
client.execute(() -> {
toast(state.handle(json));
String open = state.pendingOpen;
state.pendingOpen = null;
// A right-clicked shop point opens its window, unless another screen is already up.
if (open != null && config.enabled && client.screen == null) {
if (open.equals("market")) client.setScreen(new net.scopenet.client.screen.MarketScreen());
else if (open.equals("shop")) client.setScreen(new net.scopenet.client.screen.ShopScreen());
}
});
});
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
@@ -33,8 +33,12 @@ final class FabricConfig {
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
EssentialsConfig essentials() {
java.util.Map<String, Integer> cooldowns = new java.util.HashMap<>();
for (String cmd : new String[]{"spawn", "home", "back", "tpa", "rtp", "warp"}) cooldowns.put(cmd, Math.max(0, integer("essentials.cooldown." + cmd, 0)));
return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)),
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15);
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, Math.max(1, integer("essentials.rtp_attempts", 15)),
Math.max(0, integer("essentials.rtp_min_radius", 0)), Math.max(0, integer("essentials.max_warps", 0)),
Math.max(1, integer("essentials.home_name_max_length", 32)), cooldowns);
}
Features features(Settings s) {
@@ -42,6 +46,13 @@ final class FabricConfig {
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
}
net.scopenet.core.RewardRules rewards() {
net.scopenet.core.RewardRules d = net.scopenet.core.RewardRules.defaults();
return new net.scopenet.core.RewardRules(bool("rewards.enabled", true), bool("rewards.allow_commands", false),
text("rewards.permission_command", d.permissionCommand()), text("rewards.permission_deny_command", d.permissionDenyCommand()),
text("rewards.permission_temp_command", d.permissionTempCommand()), text("rewards.group_command", d.groupCommand()));
}
/** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */
boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); }
}
@@ -15,12 +15,17 @@ import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.BlockPos;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.*;
import net.scopenet.core.map.MapService;
import net.scopenet.fabric.api.ScopenetEconomy;
import net.scopenet.integration.Integration;
import net.scopenet.integration.Settings;
@@ -49,6 +54,9 @@ public final class FabricFeatures implements Runnable {
private ClientLink link;
private Perms perms;
private FabricConfig config;
private ActionPoller poller;
private net.scopenet.core.RewardPoller rewards;
private MapService map;
private int ticks;
private String placeholderStatus = "off";
@@ -64,6 +72,17 @@ public final class FabricFeatures implements Runnable {
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
return true;
});
// Right-clicking a shop point opens the market or the shop.
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
if (level.isClientSide || hand != InteractionHand.MAIN_HAND || !(player instanceof ServerPlayer sp) || commands == null) return InteractionResult.PASS;
BlockPos at = hit.getBlockPos();
Optional<ShopPoints.Shop> shop = commands.shops.at(level.dimension().location().toString(), at.getX(), at.getY(), at.getZ());
if (shop.isEmpty()) return InteractionResult.PASS;
CorePlayer p = platform.wrap(sp);
String kind = shop.get().kind().equals("market") ? "market" : "shop";
if (!link.open(p, kind)) commands.run(kind, p);
return InteractionResult.SUCCESS;
});
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data);
@@ -93,6 +112,12 @@ public final class FabricFeatures implements Runnable {
integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
// Actions from the map, and the claims and pins the SCOPENET Map draws.
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
rewards = new net.scopenet.core.RewardPoller(env, config.rewards());
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
config.bool("map.show_homes", false), s.getMotd());
integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); });
registerCommands(s.getCommands().getDispatcher());
registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
@@ -105,6 +130,7 @@ public final class FabricFeatures implements Runnable {
Bridge.claimBypass = p -> false;
if (platform != null) platform.shutdown();
commands = null;
map = null;
}
private void registerPlaceholders() {
@@ -125,6 +151,9 @@ public final class FabricFeatures implements Runnable {
if (ticks % 20 == 0) {
cache.tick(p -> link.pushState(p));
link.tick();
poller.tick();
rewards.tick();
map.tick();
}
if (ticks % 40 == 0) commands.tick();
}
@@ -72,4 +72,26 @@ final class FabricPlatform implements Platform {
@Override public void runMain(Runnable task) { server.execute(task); }
@Override public void runAsync(Runnable task) { io.execute(task); }
@Override public boolean giveItem(UUID id, String item, int amount) {
ServerPlayer p = server.getPlayerList().getPlayer(id);
ResourceLocation location = ResourceLocation.tryParse(item);
if (p == null || location == null) return false;
var found = net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(location);
if (found.isEmpty() || found.get() == net.minecraft.world.item.Items.AIR) return false;
net.minecraft.world.item.Item type = found.get();
for (int left = amount; left > 0; ) {
int n = Math.min(left, type.getMaxStackSize());
net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(type, n);
p.getInventory().add(stack);
if (!stack.isEmpty()) p.drop(stack, false);
left -= n;
}
return true;
}
@Override public boolean console(String command) {
server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command);
return true;
}
}
@@ -16,8 +16,11 @@ final class Perms {
static boolean luckPermsPresent() { return LUCKPERMS; }
/** Nodes that grant extras (limit tiers like scopenet.homes.10): nobody has them unless a permissions system says so. */
static boolean isGrantNode(String node) { return node.startsWith("scopenet.homes.") || node.startsWith("scopenet.limit."); }
static boolean isAdminNode(String node) {
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
return node.equals("scopenet.claims.bypass") || node.equals("scopenet.cooldown.bypass") || node.startsWith("scopenet.admin")
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
}
@@ -30,6 +33,7 @@ final class Perms {
// LuckPerms not ready yet or an API change: fall back to the defaults below.
}
}
if (isGrantNode(node)) return false;
if (isAdminNode(node)) return player.hasPermissions(2);
return everyoneByDefault || player.hasPermissions(2);
}
@@ -33,24 +33,13 @@ public final class Bridge {
}));
integration.startClaimSync();
Path config = Path.of("config/scopenet.properties");
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder());
if (Settings.mapEnabled(config)) integration.enableMap(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT), Path.of("config/scopenet/map"));
} catch (Exception e) {
// Throwing aborts startup; silently disabling an auth mod is unsafe.
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
}
}
/** The level folder: `level-name` from server.properties, like the game itself reads it. */
private static Path worldFolder() {
Properties props = new Properties();
try (var in = java.nio.file.Files.newBufferedReader(Path.of("server.properties"))) {
props.load(in);
} catch (java.io.IOException ignored) {
// Fall back to the default level name.
}
return Path.of(props.getProperty("level-name", "world"));
}
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
Integration current = integration;
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
@@ -82,13 +71,13 @@ public final class Bridge {
current.tick();
if (++mapTicks >= 20) {
mapTicks = 0;
List<LiveMapSync.Player> positions = new ArrayList<>();
List<net.scopenet.worldmap.WorldMapSync.Player> positions = new ArrayList<>();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0,
Math.round(player.getYRot()), Math.round(player.getXRot())));
}
current.liveMapPlayers(positions);
current.mapPlayers(positions);
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
}
@@ -0,0 +1,57 @@
package net.scopenet.paper;
import net.scopenet.api.ScopenetPlayer;
import net.scopenet.paper.compat.ScopenetApiImpl;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Formats player chat from the cached SCOPENET profile without a network call on the chat thread. */
public final class ChatFormatter {
private static final Pattern MARKDOWN = Pattern.compile("(\\*\\*(.+?)\\*\\*|__(.+?)__|~~(.+?)~~|`(.+?)`|\\*(.+?)\\*)");
private static final Pattern HEX = Pattern.compile("&#([0-9a-fA-F]{6})");
private final ScopenetApiImpl profiles;
public ChatFormatter(ScopenetApiImpl profiles) { this.profiles = profiles; }
private static String clean(String value) {
return value == null ? "" : ChatColor.stripColor(value).replace('%', ' ').replace('[', ' ').replace(']', ' ').trim();
}
public String format(Player player) {
ScopenetPlayer p = profiles.peek(player.getUniqueId()).orElse(null);
String guild = p == null || p.guild() == null ? "" : "§3[" + clean(p.guild().tag()) + "] ";
String title = p == null || p.rankTitle() == null || p.rankTitle().isBlank() ? "Novice" : clean(p.rankTitle());
String group = p == null || p.rankGroup() == null || p.rankGroup().isBlank() ? "Member"
: p.rankDisplay() == null || p.rankDisplay().isBlank() ? clean(p.rankGroup()) : clean(p.rankDisplay());
String prefix = p == null ? "" : p.rankPrefix();
String suffix = p == null ? "" : p.rankSuffix();
String decorated = prefix == null || prefix.isBlank() ? "" : ChatColor.translateAlternateColorCodes('&', prefix.replace("%", "%%")) + " ";
String name = player.getDisplayName().replace("%", "%%");
String tail = suffix == null || suffix.isBlank() ? "" : " " + ChatColor.translateAlternateColorCodes('&', suffix.replace("%", "%%"));
return guild + "§6[" + title + "] §b[" + group + "] §r" + decorated + name + tail + "§7: §f%2$s";
}
public String message(Player player, String raw) {
String input = player.hasPermission("scopenet.chat.color") ? ChatColor.translateAlternateColorCodes('&', raw) : ChatColor.stripColor(raw);
if (player.hasPermission("scopenet.chat.color")) {
Matcher hex = HEX.matcher(input);
StringBuffer colored = new StringBuffer();
while (hex.find()) hex.appendReplacement(colored, Matcher.quoteReplacement(net.md_5.bungee.api.ChatColor.of('#' + hex.group(1)).toString()));
hex.appendTail(colored);
input = colored.toString();
}
Matcher matcher = MARKDOWN.matcher(input);
StringBuffer out = new StringBuffer();
while (matcher.find()) {
String code = matcher.group(2) != null ? "§l" : matcher.group(3) != null ? "§n" : matcher.group(4) != null ? "§m" : matcher.group(5) != null ? "§7" : "§o";
String text = matcher.group(2) != null ? matcher.group(2) : matcher.group(3) != null ? matcher.group(3)
: matcher.group(4) != null ? matcher.group(4) : matcher.group(5) != null ? matcher.group(5) : matcher.group(6);
matcher.appendReplacement(out, Matcher.quoteReplacement(code + text + "§r§f"));
}
matcher.appendTail(out);
return out.toString();
}
}
@@ -1,6 +1,11 @@
package net.scopenet.paper;
import net.scopenet.core.*;
import net.scopenet.core.map.MapService;
import net.scopenet.core.map.MapSource;
import net.scopenet.paper.commands.EconomyHandler;
import net.scopenet.paper.commands.EssentialsHandler;
import net.scopenet.paper.commands.GuildHandler;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
@@ -22,9 +27,16 @@ final class ClientLinkBridge implements PluginMessageListener {
private final PlayerCache cache;
private final ClientLink link;
private final Placeholders placeholders;
private final Integration integration;
private final ShopPoints shops;
private ActionPoller poller;
private net.scopenet.core.RewardPoller rewards;
private MapService mapService;
private EconomyHandler economy;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
this.integration = integration;
PaperPlatform platform = new PaperPlatform(plugin);
Features features = new Features(true, plugin.getConfig().getBoolean("economy.enabled", true), integration.settings().guildsEnabled(),
integration.settings().socialEnabled(), integration.settings().landClaimingEnabled(),
@@ -33,6 +45,7 @@ final class ClientLinkBridge implements PluginMessageListener {
env = new Env(platform, new PanelAdapter(integration), features, Path.of(plugin.getDataFolder().getPath()), System::currentTimeMillis, new Random(), log);
cache = new PlayerCache(env);
placeholders = new Placeholders(env, cache);
shops = new ShopPoints(Path.of(plugin.getDataFolder().getPath()).resolve("shops.json"));
link = new ClientLink(env, cache, Bukkit.getServer().getName(), (p, bytes) -> {
Player target = Bukkit.getPlayer(p.uuid());
if (target != null) target.sendPluginMessage(plugin, Wire.S2C, bytes);
@@ -50,6 +63,59 @@ final class ClientLinkBridge implements PluginMessageListener {
}, 40, 20);
}
/**
* Shop points, actions from the map, and the claims and pins for the SCOPENET Map. Called once the command handlers exist; any of them may be
* null when that system is switched off.
*/
void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) {
this.economy = economy;
Bukkit.getPluginManager().registerEvents(new ShopListener(), plugin);
poller = new ActionPoller(env, (from, to) -> {
Player a = Bukkit.getPlayer(from.uuid()), b = Bukkit.getPlayer(to.uuid());
if (a != null && b != null && essentials != null) essentials.requestTpa(a, b);
});
MapSource source = new MapSource() {
@Override public Map<String, Pos> warps() { return essentials == null ? Map.of() : essentials.warpsForMap(); }
@Override public Map<UUID, Map<String, Pos>> homes() { return essentials == null ? Map.of() : essentials.homesForMap(); }
@Override public Map<String, Pos> guildHomes() { return guilds == null ? Map.of() : guilds.guildHomesForMap(); }
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName());
integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString());
var cfg = plugin.getConfig();
var defaults = net.scopenet.core.RewardRules.defaults();
rewards = new net.scopenet.core.RewardPoller(env, new net.scopenet.core.RewardRules(cfg.getBoolean("rewards.enabled", true), cfg.getBoolean("rewards.allow-commands", false),
cfg.getString("rewards.permission-command", defaults.permissionCommand()), cfg.getString("rewards.permission-deny-command", defaults.permissionDenyCommand()),
cfg.getString("rewards.permission-temp-command", defaults.permissionTempCommand()), cfg.getString("rewards.group-command", defaults.groupCommand())));
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); rewards.tick(); mapService.tick(); }, 60, 20);
}
/** /market point add|remove|list, on the block the player is looking at (or standing on). */
void shopCommand(Player player, String[] args) {
org.bukkit.block.Block looked = player.getTargetBlockExact(6);
org.bukkit.Location l = looked != null ? looked.getLocation() : player.getLocation().subtract(0, 1, 0);
shops.command(new PaperPlatform.PaperPlayer(player), args, new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getBlockX(), l.getBlockY(), l.getBlockZ(), 0, 0));
}
/** Right-clicking a shop point opens the market or shop: the client mod's window if they have it, otherwise the chest GUI. */
private final class ShopListener implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(ignoreCancelled = true)
public void onInteract(org.bukkit.event.player.PlayerInteractEvent event) {
if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK || event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) return;
org.bukkit.block.Block block = event.getClickedBlock();
if (block == null) return;
Optional<ShopPoints.Shop> shop = shops.at(ScopenetPlugin.dimension(block.getWorld()), block.getX(), block.getY(), block.getZ());
if (shop.isEmpty() || economy == null) return;
event.setCancelled(true);
Player player = event.getPlayer();
String kind = shop.get().kind().equals("market") ? "market" : "shop";
if (!link.open(new PaperPlatform.PaperPlayer(player), kind)) {
if (kind.equals("market")) economy.openMarket(player); else economy.openShop(player);
}
}
}
void stop() {
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
@@ -78,12 +144,34 @@ final class ClientLinkBridge implements PluginMessageListener {
return out;
}
@Override public Optional<Pos> safeSurface(String world, int x, int z) { return Optional.empty(); }
@Override public Pos worldSpawn(String world) { return new Pos(world, 0, 64, 0, 0, 0); }
@Override public Pos worldSpawn(String world) {
for (org.bukkit.World w : Bukkit.getWorlds()) {
if (ScopenetPlugin.dimension(w).equals(world)) { org.bukkit.Location l = w.getSpawnLocation(); return new Pos(world, l.getX(), l.getY(), l.getZ(), 0, 0); }
}
org.bukkit.Location l = Bukkit.getWorlds().get(0).getSpawnLocation();
return new Pos(ScopenetPlugin.dimension(Bukkit.getWorlds().get(0)), l.getX(), l.getY(), l.getZ(), 0, 0);
}
@Override public void runMain(Runnable task) {
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
}
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
@Override public boolean giveItem(UUID id, String item, int amount) {
Player p = Bukkit.getPlayer(id);
if (p == null) return false;
Material m = Material.matchMaterial(item);
if (m == null && item.startsWith("minecraft:")) m = Material.matchMaterial(item.substring(10));
if (m == null || !m.isItem() || m == Material.AIR) return false;
for (int left = amount; left > 0; ) {
int n = Math.min(left, m.getMaxStackSize());
for (ItemStack over : p.getInventory().addItem(new ItemStack(m, n)).values()) p.getWorld().dropItemNaturally(p.getLocation(), over);
left -= n;
}
return true;
}
@Override public boolean console(String command) { return Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); }
record PaperPlayer(Player p) implements CorePlayer {
@Override public UUID uuid() { return p.getUniqueId(); }
@Override public String name() { return p.getName(); }
@@ -25,6 +25,9 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private ScopenetApiImpl api;
private CompatManager compat;
private ClientLinkBridge clientLink;
private EssentialsHandler essentialsHandler;
private GuildHandler guildHandler;
private ChatFormatter chatFormatter;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -48,15 +51,25 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Claim protection runs from a local copy of the panel's claims, refreshed in the background.
integration.startClaimSync();
// Central Live Map: mirror chunks and player positions to the panel.
if (getConfig().getBoolean("livemap.enabled", true)) {
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath());
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
// The SCOPENET Map: renders the world to tiles and sends them, positions, claims and pins to the panel.
// Started on the first tick so the worlds exist.
if (getConfig().getBoolean("map.enabled", getConfig().getBoolean("livemap.enabled", true))) {
Bukkit.getScheduler().runTask(this, () -> {
if (integration == null || Bukkit.getWorlds().isEmpty()) return;
integration.enableMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), getDataFolder().toPath().resolve("map"));
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
});
getServer().getPluginManager().registerEvents(new org.bukkit.event.Listener() {
@org.bukkit.event.EventHandler public void onSave(org.bukkit.event.world.WorldSaveEvent event) {
if (integration != null) integration.mapSaved();
}
}, this);
}
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
// and receive level-ups, achievements and guild changes as ordinary Bukkit events.
api = new ScopenetApiImpl(integration.client());
chatFormatter = new ChatFormatter(api);
ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
// Optional client mod (HUD, claim overlay, shop and market windows).
@@ -78,10 +91,18 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Essentials commands & GUI
{
EssentialsHandler essentials = new EssentialsHandler(this);
essentialsHandler = essentials;
getServer().getPluginManager().registerEvents(essentials, this);
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"};
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "reqwarp", "warprequests", "warpapprove", "warpdeny", "setwarp", "delwarp", "playtime"};
for (String c : essCmds) bindCommand(c, essentials);
}
HandHandler hand = new HandHandler(this);
getServer().getPluginManager().registerEvents(hand, this);
bindCommand("hand", hand);
bindCommand("handview", hand);
NicknameHandler nick = new NicknameHandler(this);
getServer().getPluginManager().registerEvents(nick, this);
bindCommand("nick", nick);
// Register Economy commands & GUI
{
@@ -94,12 +115,17 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Guilds & Land Claims commands & GUI
{
GuildHandler guilds = new GuildHandler(this, integration, economy);
guildHandler = guilds;
getServer().getPluginManager().registerEvents(guilds, this);
bindCommand("guild", guilds);
bindCommand("claim", guilds);
bindCommand("unclaim", guilds);
bindCommand("autoclaim", guilds);
}
// Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws.
clientLink.startMap(essentialsHandler, guildHandler, economy);
// Reloading an auth plugin cannot silently admit existing sessions.
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
getLogger().info("SCOPENET integration initialized. Configured modules:");
@@ -111,7 +137,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED"));
getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED"));
} catch (Exception e) {
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
getLogger().log(java.util.logging.Level.SEVERE, "SCOPENET is blocking joins", e);
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
}
}
@@ -143,7 +169,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private void snapshotPlayers() {
if (integration == null) return;
java.util.List<LiveMapSync.Player> list = new java.util.ArrayList<>();
java.util.List<net.scopenet.worldmap.WorldMapSync.Player> list = new java.util.ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) {
Location at = p.getLocation();
String dim = switch (at.getWorld().getEnvironment()) {
@@ -152,22 +178,22 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
default -> "minecraft:overworld";
};
// Rounded so an idle roster doesn't look "changed" on every tick.
list.add(new LiveMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
list.add(new net.scopenet.worldmap.WorldMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0,
Math.round(at.getYaw()), Math.round(at.getPitch())));
}
integration.liveMapPlayers(list);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void worldSaved(org.bukkit.event.world.WorldSaveEvent event) {
if (integration != null) integration.liveMapSaved();
integration.mapPlayers(list);
}
public static String dimension(World world) {
return world.getKey().toString();
}
/** {@code /market point ...}: manage shop points (blocks that open the market or shop when right-clicked). */
public void shopCommand(Player player, String[] args) {
if (clientLink != null) clientLink.shopCommand(player, args);
}
private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) {
org.bukkit.command.PluginCommand cmd = getCommand(name);
if (cmd != null) {
@@ -345,8 +371,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
if (claimed(event.getBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void chat(AsyncPlayerChatEvent event) {
if (integration != null && integration.settings().socialEnabled() && integration.settings().chatPrefixesEnabled() && chatFormatter != null) {
event.setFormat(chatFormatter.format(event.getPlayer()));
event.setMessage(chatFormatter.message(event.getPlayer(), event.getMessage()));
}
add(event.getPlayer(), "messages");
}
@@ -109,6 +109,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
return true;
}
/** Open the player market window (used by physical shop points). */
public void openMarket(Player player) { handleMarket(player, new String[0], false); }
/** Open the server shop window (used by physical shop points). */
public void openShop(Player player) { handleShop(player); }
private void handleBalance(Player player) {
player.sendMessage(ChatColor.GRAY + "Fetching your balance...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
@@ -269,6 +275,10 @@ public final class EconomyHandler implements CommandExecutor, Listener {
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
void handleMarket(Player player, String[] args, boolean asGuild) {
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
plugin.shopCommand(player, java.util.Arrays.copyOfRange(args, 1, args.length));
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
if (!asGuild && !player.hasPermission("scopenet.command.market.buy")) {
player.sendMessage(ChatColor.RED + "You do not have permission to buy from the market.");
@@ -27,8 +27,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
private final ScopenetPlugin plugin;
private final File homesFile;
private final File warpsFile;
private final File warpRequestsFile;
private final YamlConfiguration homesConfig;
private final YamlConfiguration warpsConfig;
private final YamlConfiguration warpRequestsConfig;
private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>();
private final Map<String, Location> warps = new ConcurrentHashMap<>();
@@ -43,8 +45,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
this.plugin = plugin;
this.homesFile = new File(plugin.getDataFolder(), "homes.yml");
this.warpsFile = new File(plugin.getDataFolder(), "warps.yml");
this.warpRequestsFile = new File(plugin.getDataFolder(), "warp_requests.yml");
this.homesConfig = YamlConfiguration.loadConfiguration(homesFile);
this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile);
this.warpRequestsConfig = YamlConfiguration.loadConfiguration(warpRequestsFile);
loadHomes();
loadWarps();
}
@@ -100,6 +104,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
}
}
private void saveWarpRequests() {
try { warpRequestsConfig.save(warpRequestsFile); }
catch (IOException e) { plugin.getLogger().warning("Failed to save warp requests: " + e.getMessage()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if (event.getFrom() != null && event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) {
@@ -124,7 +133,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
Map<String, Location> playerHomes = homes.get(player.getUniqueId());
if (playerHomes != null && playerHomes.containsKey(homeName)) {
player.closeInventory();
if (coolingDown(player, "home")) return;
player.teleport(playerHomes.get(homeName));
startCooldown(player, "home");
player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName);
}
} else if (title.equals(GUI_WARPS_TITLE)) {
@@ -134,7 +145,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase();
if (warps.containsKey(warpName)) {
player.closeInventory();
if (coolingDown(player, "warp")) return;
player.teleport(warps.get(warpName));
startCooldown(player, "warp");
player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName);
}
}
@@ -149,6 +162,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; }
String cmd = command.getName().toLowerCase();
boolean cooled = cooldownSeconds(cmd) > 0 && !player.hasPermission("scopenet.cooldown.bypass");
if (cooled && coolingDown(player, cmd)) return true;
Location before = player.getLocation();
switch (cmd) {
case "spawn" -> handleSpawn(player);
case "home" -> handleHome(player, args);
@@ -160,9 +176,16 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
case "tpdeny" -> handleTpDeny(player);
case "rtp" -> handleRtp(player);
case "warp" -> handleWarp(player, args);
case "reqwarp" -> requestWarp(player, args);
case "warprequests" -> listWarpRequests(player);
case "warpapprove" -> reviewWarp(player, args, true);
case "warpdeny" -> reviewWarp(player, args, false);
case "setwarp" -> setWarp(player, args);
case "delwarp" -> deleteWarp(player, args);
case "playtime" -> handlePlaytime(player, args);
default -> { return false; }
}
if (cooled && ((cmd.equals("tpa") && args.length > 0) || cmd.equals("rtp") || !before.equals(player.getLocation()))) startCooldown(player, cmd);
return true;
}
@@ -172,6 +195,28 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.GREEN + "Teleported to spawn.");
}
private static net.scopenet.core.Pos toPos(Location l) {
return new net.scopenet.core.Pos(l.getWorld() == null ? "minecraft:overworld" : ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
}
/** Warps as shown on the map. A copy. */
public Map<String, net.scopenet.core.Pos> warpsForMap() {
Map<String, net.scopenet.core.Pos> out = new TreeMap<>();
warps.forEach((name, loc) -> { if (loc.getWorld() != null) out.put(name, toPos(loc)); });
return out;
}
/** Every player's homes, for the map (only shown if the admin allows it). A copy. */
public Map<UUID, Map<String, net.scopenet.core.Pos>> homesForMap() {
Map<UUID, Map<String, net.scopenet.core.Pos>> out = new HashMap<>();
homes.forEach((id, byName) -> {
Map<String, net.scopenet.core.Pos> mine = new TreeMap<>();
byName.forEach((name, loc) -> { if (loc.getWorld() != null) mine.put(name, toPos(loc)); });
out.put(id, mine);
});
return out;
}
private void handleHome(Player player, String[] args) {
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
if (args.length == 0) {
@@ -226,13 +271,36 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.openInventory(inv);
}
// ---- limits and cooldowns (the same rules as the Fabric mod; see config.yml) ----
private final net.scopenet.core.Limits.Cooldowns cooldowns = new net.scopenet.core.Limits.Cooldowns();
private int tpaTimeoutSeconds() { return Math.max(5, plugin.getConfig().getInt("essentials.tpa-timeout-seconds", 60)); }
private int cooldownSeconds(String cmd) { return Math.max(0, plugin.getConfig().getInt("essentials.cooldowns." + cmd, 0)); }
/** True (and tells the player) if they must still wait before using this command. */
private boolean coolingDown(Player player, String cmd) {
if (cooldownSeconds(cmd) <= 0 || player.hasPermission("scopenet.cooldown.bypass")) return false;
long wait = cooldowns.remainingMs(player.getUniqueId(), cmd, System.currentTimeMillis());
if (wait <= 0) return false;
player.sendMessage(ChatColor.RED + "Please wait " + net.scopenet.core.Limits.Cooldowns.waitText(wait) + " before using /" + cmd + " again.");
return true;
}
private void startCooldown(Player player, String cmd) {
if (!player.hasPermission("scopenet.cooldown.bypass")) cooldowns.start(player.getUniqueId(), cmd, System.currentTimeMillis(), cooldownSeconds(cmd));
}
private void handleSetHome(Player player, String[] args) {
String homeName = args.length > 0 ? args[0].toLowerCase() : "home";
int maxHomes = plugin.getConfig().getInt("essentials.max-homes", 5);
int nameMax = Math.max(1, plugin.getConfig().getInt("essentials.home-name-max-length", 32));
if (homeName.length() > nameMax) { player.sendMessage(ChatColor.RED + "Home names can be at most " + nameMax + " characters."); return; }
int maxHomes = net.scopenet.core.Limits.tier(player::hasPermission, "scopenet.homes.", plugin.getConfig().getInt("essentials.max-homes", 5), 500);
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) {
player.sendMessage(ChatColor.RED + "You have reached your limit of " + maxHomes + " homes.");
player.sendMessage(ChatColor.RED + "You have reached your limit of " + net.scopenet.core.Limits.show(maxHomes) + " homes.");
return;
}
@@ -277,18 +345,23 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
return;
}
requestTpa(player, target);
}
/** Ask {@code target} to accept a teleport of {@code player} to them. Also used for requests made from the map. */
public void requestTpa(Player player, Player target) {
pendingTpa.put(target.getUniqueId(), player.getUniqueId());
tpaTimestamp.put(target.getUniqueId(), System.currentTimeMillis());
player.sendMessage(ChatColor.GREEN + "Teleport request sent to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + ".");
target.sendMessage(ChatColor.YELLOW + player.getName() + ChatColor.GREEN + " has sent you a teleport request.");
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in 60s).");
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in " + tpaTimeoutSeconds() + "s).");
}
private void handleTpAccept(Player player) {
UUID requesterId = pendingTpa.get(player.getUniqueId());
Long time = tpaTimestamp.get(player.getUniqueId());
if (requesterId == null || time == null || System.currentTimeMillis() - time > 60_000) {
if (requesterId == null || time == null || System.currentTimeMillis() - time > tpaTimeoutSeconds() * 1000L) {
pendingTpa.remove(player.getUniqueId());
tpaTimestamp.remove(player.getUniqueId());
player.sendMessage(ChatColor.RED + "You have no active teleport requests.");
@@ -324,15 +397,18 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
}
private void handleRtp(Player player) {
int radius = plugin.getConfig().getInt("essentials.rtp-radius", 2500);
int radius = Math.max(10, plugin.getConfig().getInt("essentials.rtp-radius", 2500));
int minRadius = Math.min(plugin.getConfig().getInt("essentials.rtp-min-radius", 0), radius - 1);
int attempts = Math.max(1, plugin.getConfig().getInt("essentials.rtp-attempts", 15));
World world = player.getWorld();
Random rng = new Random();
player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination...");
Bukkit.getScheduler().runTask(plugin, () -> {
for (int i = 0; i < 15; i++) {
for (int i = 0; i < attempts; i++) {
int x = rng.nextInt(radius * 2) - radius;
int z = rng.nextInt(radius * 2) - radius;
if (Math.max(Math.abs(x), Math.abs(z)) < minRadius) continue;
int y = world.getHighestBlockYAt(x, z);
Block block = world.getBlockAt(x, y - 1, z);
if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) {
@@ -362,6 +438,93 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.GREEN + "Teleported to warp " + ChatColor.YELLOW + warpName + ChatColor.GREEN + ".");
}
private String warpName(Player player, String[] args, String usage) {
if (args.length != 1 || !args[0].matches("[A-Za-z0-9_-]{2,24}")) {
player.sendMessage(ChatColor.RED + "Usage: " + usage + " (2–24 letters, numbers, _ or -)");
return null;
}
return args[0].toLowerCase(Locale.ROOT);
}
private void requestWarp(Player player, String[] args) {
String name = warpName(player, args, "/reqwarp <name>");
if (name == null) return;
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "That warp already exists."); return; }
if (warpRequestsConfig.contains(name) && !player.getUniqueId().toString().equals(warpRequestsConfig.getString(name + ".uuid"))) {
player.sendMessage(ChatColor.RED + "That warp name has already been requested."); return;
}
warpRequestsConfig.set(name + ".location", player.getLocation());
warpRequestsConfig.set(name + ".uuid", player.getUniqueId().toString());
warpRequestsConfig.set(name + ".requester", player.getName());
warpRequestsConfig.set(name + ".created_at", System.currentTimeMillis());
saveWarpRequests();
player.sendMessage(ChatColor.GREEN + "Warp request sent. A server admin can approve it with /warpapprove " + name + ".");
Bukkit.getOnlinePlayers().stream().filter(p -> p.hasPermission("scopenet.command.warp.manage"))
.forEach(p -> p.sendMessage(ChatColor.YELLOW + player.getName() + " requested warp '" + name + "'. Use /warprequests to review."));
}
private boolean warpAdmin(Player player) {
if (player.hasPermission("scopenet.command.warp.manage")) return true;
player.sendMessage(ChatColor.RED + "Only server admins can manage warps.");
return false;
}
private void listWarpRequests(Player player) {
if (!warpAdmin(player)) return;
if (warpRequestsConfig.getKeys(false).isEmpty()) { player.sendMessage(ChatColor.YELLOW + "No pending warp requests."); return; }
player.sendMessage(ChatColor.GOLD + "Pending warp requests:");
for (String name : warpRequestsConfig.getKeys(false)) {
Location at = warpRequestsConfig.getLocation(name + ".location");
player.sendMessage(ChatColor.YELLOW + name + ChatColor.GRAY + " by " + warpRequestsConfig.getString(name + ".requester", "unknown")
+ (at == null || at.getWorld() == null ? "" : " at " + at.getWorld().getName() + " " + at.getBlockX() + ", " + at.getBlockY() + ", " + at.getBlockZ()));
}
}
private void reviewWarp(Player player, String[] args, boolean approve) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, approve ? "/warpapprove <name>" : "/warpdeny <name>");
if (name == null) return;
Location at = warpRequestsConfig.getLocation(name + ".location");
if (at == null || at.getWorld() == null) { player.sendMessage(ChatColor.RED + "No pending request with a loaded world has that name."); return; }
if (approve) {
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "Warp already exists. Deny this request or choose another name."); return; }
warps.put(name, at);
saveWarps();
}
String uuid = warpRequestsConfig.getString(name + ".uuid", "");
warpRequestsConfig.set(name, null);
saveWarpRequests();
player.sendMessage(ChatColor.GREEN + "Warp request " + (approve ? "approved" : "denied") + ": " + name);
try {
Player requester = Bukkit.getPlayer(UUID.fromString(uuid));
if (requester != null) requester.sendMessage(ChatColor.YELLOW + "Your warp request '" + name + "' was " + (approve ? "approved" : "denied") + ".");
} catch (IllegalArgumentException ignored) {}
}
private void setWarp(Player player, String[] args) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, "/setwarp <name>");
if (name == null) return;
int maxWarps = plugin.getConfig().getInt("essentials.max-warps", 0);
if (maxWarps > 0 && !warps.containsKey(name) && warps.size() >= maxWarps) {
player.sendMessage(ChatColor.RED + "The server already has " + maxWarps + " warps. Delete one first.");
return;
}
warps.put(name, player.getLocation());
saveWarps();
player.sendMessage(ChatColor.GREEN + "Warp set: " + name);
}
private void deleteWarp(Player player, String[] args) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, "/delwarp <name>");
if (name == null) return;
if (warps.remove(name) == null) { player.sendMessage(ChatColor.RED + "Warp not found."); return; }
warpsConfig.set(name, null);
saveWarps();
player.sendMessage(ChatColor.GREEN + "Warp deleted: " + name);
}
private void openWarpPicker(Player player) {
Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE);
int slot = 11;
@@ -20,6 +20,8 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
@@ -34,6 +36,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
private final File guildHomesFile;
private final YamlConfiguration guildHomesConfig;
private final Map<String, Location> guildHomes = new ConcurrentHashMap<>();
private final Set<UUID> autoClaim = ConcurrentHashMap.newKeySet();
private final Map<UUID, String> lastAutoClaim = new ConcurrentHashMap<>();
private static final String GUI_GUILD_TITLE = ChatColor.DARK_AQUA + "Guild Dashboard";
@@ -86,6 +90,23 @@ public final class GuildHandler implements CommandExecutor, Listener {
handleUnclaim(player);
return true;
}
if (cmd.equals("autoclaim")) {
if (!player.hasPermission("scopenet.command.claim")) { player.sendMessage(ChatColor.RED + "You cannot claim land."); return true; }
if (args.length != 1 || (!args[0].equalsIgnoreCase("on") && !args[0].equalsIgnoreCase("off"))) {
player.sendMessage(ChatColor.YELLOW + "Usage: /autoclaim on|off"); return true;
}
if (args[0].equalsIgnoreCase("on")) {
autoClaim.add(player.getUniqueId());
lastAutoClaim.remove(player.getUniqueId());
player.sendMessage(ChatColor.GREEN + "Auto-claim is on. New chunks you walk into will be claimed for your guild.");
claimOnMove(player, player.getLocation());
} else {
autoClaim.remove(player.getUniqueId());
lastAutoClaim.remove(player.getUniqueId());
player.sendMessage(ChatColor.YELLOW + "Auto-claim is off.");
}
return true;
}
if (cmd.equals("guild") || cmd.equals("g")) {
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
@@ -99,7 +120,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
String sub = args[0].toLowerCase();
String permission = sub.equals("c") ? "chat" : sub;
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay").contains(permission)
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline").contains(permission)
&& !player.hasPermission("scopenet.command.guild." + permission)) {
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
return true;
@@ -124,6 +145,9 @@ public final class GuildHandler implements CommandExecutor, Listener {
case "members" -> handleMembers(player);
case "bank" -> bank.bank(player, Arrays.copyOfRange(args, 1, args.length));
case "pay" -> bank.pay(player, Arrays.copyOfRange(args, 1, args.length));
case "invite" -> handleInvite(player, args);
case "accept" -> handleInviteAnswer(player, args, true);
case "decline", "deny" -> handleInviteAnswer(player, args, false);
case "sell" -> economy.handleSell(player, Arrays.copyOfRange(args, 1, args.length), true);
case "market" -> economy.handleMarket(player, Arrays.copyOfRange(args, 1, args.length), true);
default -> sendGuildHelp(player);
@@ -134,6 +158,46 @@ public final class GuildHandler implements CommandExecutor, Listener {
return false;
}
private void handleInvite(Player player, String[] args) {
if (args.length < 2) { player.sendMessage(ChatColor.RED + "Usage: /guild invite <player>"); return; }
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
body.addProperty("uuid", player.getUniqueId().toString());
body.addProperty("target", args[1]);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
com.google.gson.JsonObject r = integration.client().post("guilds/invite/send", body);
player.sendMessage(ChatColor.GREEN + "Invitation sent to " + ChatColor.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[1]) + ChatColor.GREEN + ".");
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Could not invite: " + e.getMessage());
}
});
}
private void handleInviteAnswer(Player player, String[] args, boolean accept) {
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
body.addProperty("uuid", player.getUniqueId().toString());
body.addProperty("tag", args.length > 1 ? args[1] : "");
body.addProperty("accept", accept);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
com.google.gson.JsonObject r = integration.client().post("guilds/invite/respond", body);
if (accept) integration.client().requestClaimRefresh();
player.sendMessage(accept
? ChatColor.GREEN + "Welcome to " + ChatColor.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + ChatColor.GREEN + "!"
: ChatColor.YELLOW + "Invitation declined.");
} catch (Exception e) {
player.sendMessage(ChatColor.RED + e.getMessage());
}
});
}
/** Guild homes by guild id, for the map. */
public Map<String, net.scopenet.core.Pos> guildHomesForMap() {
Map<String, net.scopenet.core.Pos> out = new java.util.TreeMap<>();
guildHomes.forEach((id, l) -> { if (l.getWorld() != null) out.put(id, new net.scopenet.core.Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch())); });
return out;
}
private void sendGuildHelp(Player player) {
player.sendMessage(ChatColor.GOLD + "=== SCOPENET Guild Commands ===");
player.sendMessage(ChatColor.YELLOW + "/guild" + ChatColor.GRAY + " - Open interactive Guild Dashboard GUI");
@@ -148,6 +212,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.YELLOW + "/guild sell [hand]" + ChatColor.GRAY + " - Sell items to the shop for the guild bank");
player.sendMessage(ChatColor.YELLOW + "/guild market sell <price> | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild");
player.sendMessage(ChatColor.YELLOW + "/guild pay <tag> <amount>" + ChatColor.GRAY + " - Pay another guild");
player.sendMessage(ChatColor.YELLOW + "/guild invite <player>" + ChatColor.GRAY + " - Invite a player (leaders and officers)");
player.sendMessage(ChatColor.YELLOW + "/guild accept [tag] | decline [tag]" + ChatColor.GRAY + " - Answer a guild invitation");
}
private void handleClaim(Player player) {
@@ -166,6 +232,39 @@ public final class GuildHandler implements CommandExecutor, Listener {
});
}
@EventHandler
public void onMove(PlayerMoveEvent event) {
if (event.getTo() == null || !autoClaim.contains(event.getPlayer().getUniqueId())) return;
if (event.getFrom().getWorld() == event.getTo().getWorld()
&& event.getFrom().getChunk().getX() == event.getTo().getChunk().getX()
&& event.getFrom().getChunk().getZ() == event.getTo().getChunk().getZ()) return;
claimOnMove(event.getPlayer(), event.getTo());
}
@EventHandler public void onQuit(PlayerQuitEvent event) {
autoClaim.remove(event.getPlayer().getUniqueId());
lastAutoClaim.remove(event.getPlayer().getUniqueId());
}
private void claimOnMove(Player player, Location location) {
if (!integration.settings().landClaimingEnabled()) return;
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
String dim = ScopenetPlugin.dimension(location.getWorld());
String key = dim + ':' + x + ':' + z;
if (key.equals(lastAutoClaim.put(player.getUniqueId(), key))) return;
if (integration.isClaimed(dim, x, z)) return;
UUID uuid = player.getUniqueId();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
integration.client().claimChunk(dim, x, z, uuid);
player.sendMessage(ChatColor.GREEN + "Auto-claimed chunk [" + x + ", " + z + "].");
} catch (Exception e) {
autoClaim.remove(uuid);
player.sendMessage(ChatColor.RED + "Auto-claim stopped: " + e.getMessage());
}
});
}
private void handleUnclaim(Player player) {
Chunk chunk = player.getLocation().getChunk();
String dim = ScopenetPlugin.dimension(player.getWorld());
@@ -0,0 +1,71 @@
package net.scopenet.paper.commands;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.ComponentBuilder;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** Share a read-only snapshot of the held item through a clickable chat message. */
public final class HandHandler implements CommandExecutor, Listener {
private record Shared(ItemStack item, long expires) {}
private final Map<String, Shared> shares = new ConcurrentHashMap<>();
private static final String TITLE = ChatColor.DARK_GRAY + "Shared item";
public HandHandler(Plugin plugin) {
Bukkit.getScheduler().runTaskTimer(plugin, () -> shares.entrySet().removeIf(e -> e.getValue().expires < System.currentTimeMillis()), 1200, 1200);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can use this command."); return true; }
if (command.getName().equalsIgnoreCase("handview")) {
if (args.length != 1) return false;
Shared shared = shares.get(args[0]);
if (shared == null || shared.expires < System.currentTimeMillis()) { player.sendMessage(ChatColor.RED + "That item share has expired."); return true; }
Inventory view = Bukkit.createInventory(null, 9, TITLE);
view.setItem(4, shared.item.clone());
player.openInventory(view);
return true;
}
ItemStack item = player.getInventory().getItemInMainHand();
if (item.getType() == Material.AIR) { player.sendMessage(ChatColor.RED + "Hold an item first."); return true; }
String token = UUID.randomUUID().toString().substring(0, 8);
shares.put(token, new Shared(item.clone(), System.currentTimeMillis() + 300_000));
String name = item.hasItemMeta() && item.getItemMeta().hasDisplayName()
? item.getItemMeta().getDisplayName() : item.getType().name().toLowerCase().replace('_', ' ');
TextComponent link = new TextComponent("[" + name + " ×" + item.getAmount() + "]");
link.setColor(net.md_5.bungee.api.ChatColor.AQUA);
link.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/handview " + token));
link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("Click to inspect " + name + " (5 minutes)").create()));
TextComponent line = new TextComponent(player.getDisplayName() + ChatColor.WHITE + " is holding ");
line.addExtra(link);
for (Player target : Bukkit.getOnlinePlayers()) {
target.spigot().sendMessage(line);
}
return true;
}
@EventHandler public void onInventoryClick(InventoryClickEvent event) {
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
}
@EventHandler public void onInventoryDrag(InventoryDragEvent event) {
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
}
}
@@ -0,0 +1,63 @@
package net.scopenet.paper.commands;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.plugin.Plugin;
import java.io.File;
import java.io.IOException;
/** Player-owned chat nicknames, keyed by UUID and restored on every join. */
public final class NicknameHandler implements CommandExecutor, Listener {
private final Plugin plugin;
private final File file;
private final YamlConfiguration config;
public NicknameHandler(Plugin plugin) {
this.plugin = plugin;
this.file = new File(plugin.getDataFolder(), "nicknames.yml");
this.config = YamlConfiguration.loadConfiguration(file);
}
@EventHandler public void join(PlayerJoinEvent event) {
Player player = event.getPlayer();
String saved = config.getString(player.getUniqueId().toString());
if (saved != null && !saved.isBlank()) player.setDisplayName(ChatColor.translateAlternateColorCodes('&', saved));
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can set a nickname."); return true; }
if (args.length != 1) { player.sendMessage(ChatColor.YELLOW + "Usage: /nick <name|off>"); return true; }
String value = args[0];
if (value.equalsIgnoreCase("off")) {
config.set(player.getUniqueId().toString(), null);
player.setDisplayName(player.getName());
save();
player.sendMessage(ChatColor.GREEN + "Nickname removed.");
return true;
}
String plain = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', value));
if (plain.length() < 2 || plain.length() > 24 || !plain.matches("[A-Za-z0-9_ -]+")) {
player.sendMessage(ChatColor.RED + "Nicknames must be 2–24 letters, numbers, spaces, _ or -.");
return true;
}
if (!player.hasPermission("scopenet.chat.color")) value = plain;
config.set(player.getUniqueId().toString(), value);
player.setDisplayName(ChatColor.translateAlternateColorCodes('&', value));
save();
player.sendMessage(ChatColor.GREEN + "Nickname set to " + player.getDisplayName() + ChatColor.GREEN + ".");
return true;
}
private void save() {
try { config.save(file); }
catch (IOException e) { plugin.getLogger().warning("Failed to save nicknames: " + e.getMessage()); }
}
}
@@ -102,6 +102,7 @@ public final class LuckPermsModule implements IntegrationModule {
JsonObject config = answer.has("config") && answer.get("config").isJsonObject() ? answer.getAsJsonObject("config") : new JsonObject();
if (config.has("mode")) lastMode = config.get("mode").getAsString();
if (context.getBoolean("luckperms", "apply-panel-groups", false) && config.has("assign")) apply(config.getAsJsonArray("assign"));
if (config.has("level_assign")) apply(config.getAsJsonArray("level_assign"));
} catch (Exception e) {
context.log().fine("LuckPerms report failed: " + e.getMessage());
}
@@ -69,7 +69,7 @@ public final class ScopenetApiImpl implements ScopenetApi, AutoCloseable {
guild == null ? null : new GuildSummary(str(guild, "id"), str(guild, "name"), str(guild, "tag"), str(guild, "role"), (int) num(guild, "claims")),
dbl(j, "balance"), num(j, "playtime_secs"), num(j, "server_playtime_secs"), (int) num(j, "kills"), (int) num(j, "deaths"),
(int) num(j, "friends"), (int) num(j, "achievements"), quests(j, "daily"), quests(j, "weekly"),
str(j, "joined"), str(rank, "group"), str(rank, "prefix"));
str(j, "joined"), str(rank, "group"), str(rank, "display"), str(rank, "prefix"), str(rank, "suffix"));
}
static ScopenetGuild guild(JsonObject j) {
@@ -46,16 +46,38 @@ social:
essentials:
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
enabled: true
# Maximum homes per player
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
# scopenet.homes.25 gives 25 (the highest one a player has wins), scopenet.homes.unlimited removes the limit.
# Operators and anyone with "*" get the top tier.
max-homes: 5
# RTP search radius
# Longest allowed home name.
home-name-max-length: 32
# Most warps the server may have (0 = no limit).
max-warps: 0
# How long a /tpa request stays open.
tpa-timeout-seconds: 60
# /rtp picks a random spot between rtp-min-radius and rtp-radius blocks from the centre (0,0).
rtp-radius: 2500
rtp-min-radius: 0
# How many random spots /rtp tries before giving up.
rtp-attempts: 15
# Seconds a player must wait between uses (0 = no cooldown). The wait starts when the command actually
# teleports, so a typo costs nothing. Players with scopenet.cooldown.bypass skip all of these.
cooldowns:
spawn: 0
home: 0
back: 0
tpa: 0
rtp: 0
warp: 0
livemap:
# Send world chunks and live player positions to the panel's central Live Map.
# Players open it from the launcher; you don't run Vantage on this server.
# It also needs "Live Map" switched on for this server in the panel.
map:
# The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed,
# with player positions, guild claims and pins, to the panel. Players see it in the launcher; admins in the panel.
# It also needs the map switched on for this server in the panel (it is by default).
enabled: true
# Show every player's homes as pins. Off by default: homes are private.
show-homes: false
economy:
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
@@ -94,6 +116,23 @@ integrations:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# Rewards
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
# to this server when the player is online. Permissions and groups are applied with console commands, so any
# permissions plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
# ==============================================================================
rewards:
enabled: true
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the panel
# could otherwise run commands on this server. Only switch it on if you trust everyone with panel admin access.
allow-commands: false
permission-command: "lp user {player} permission set {node} true"
permission-deny-command: "lp user {player} permission set {node} false"
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
group-command: "lp user {player} parent add {group}"
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -50,10 +50,37 @@ commands:
permission: scopenet.command.warp
description: Teleport to a server warp or open the warps GUI
aliases: [warps]
reqwarp:
permission: scopenet.command.reqwarp
description: Request a new public warp at your location
warprequests:
permission: scopenet.command.warp.manage
description: List pending warp requests
warpapprove:
permission: scopenet.command.warp.manage
description: Approve a pending warp request
warpdeny:
permission: scopenet.command.warp.manage
description: Deny a pending warp request
setwarp:
permission: scopenet.command.warp.manage
description: Set a public warp at your location
delwarp:
permission: scopenet.command.warp.manage
description: Delete a public warp
playtime:
permission: scopenet.command.playtime
description: Check total and session playtime
aliases: [ontime]
hand:
permission: scopenet.command.hand
description: Share your held item in chat
handview:
permission: scopenet.command.hand
description: Inspect a shared item
nick:
permission: scopenet.command.nick
description: Set or remove your chat nickname
balance:
permission: scopenet.command.balance
description: Check your server economy balance
@@ -94,6 +121,9 @@ commands:
unclaim:
permission: scopenet.command.unclaim
description: Unclaim the current chunk
autoclaim:
permission: scopenet.command.claim
description: Claim chunks as you walk into them
permissions:
@@ -137,9 +167,21 @@ permissions:
scopenet.command.warp:
description: Use /warp
default: true
scopenet.command.reqwarp:
description: Request a public warp
default: true
scopenet.command.warp.manage:
description: Review and manage public warps
default: op
scopenet.command.playtime:
description: Use /playtime
default: true
scopenet.command.hand:
description: Share and inspect held items in chat
default: true
scopenet.command.nick:
description: Set or remove your chat nickname
default: true
scopenet.command.balance:
description: Use /balance
default: true
@@ -176,6 +218,9 @@ permissions:
scopenet.command.unclaim:
description: Use /unclaim
default: true
scopenet.chat.color:
description: Use Minecraft color codes in chat
default: op
scopenet.command.scopenet.help:
description: Use /scopenet help
default: true
@@ -242,6 +287,18 @@ permissions:
scopenet.command.guild.pay:
description: Pay another guild (/guild pay)
default: true
scopenet.command.guild.invite:
description: Invite a player to your guild (/guild invite); the panel also requires leader or officer
default: true
scopenet.command.guild.accept:
description: Accept a guild invitation (/guild accept)
default: true
scopenet.command.guild.decline:
description: Decline a guild invitation (/guild decline)
default: true
scopenet.command.market.point:
description: Create and remove shop points, blocks that open the market or shop (/market point)
default: op
scopenet.command.sell.hand:
description: Use /sell hand
default: true
@@ -278,6 +335,9 @@ permissions:
scopenet.command.guild.market.sell: true
scopenet.command.guild.market.buy: true
scopenet.command.guild.pay: true
scopenet.command.guild.invite: true
scopenet.command.guild.accept: true
scopenet.command.guild.decline: true
scopenet.command.*:
description: Every SCOPENET command and subcommand, including admin ones
default: false
@@ -328,8 +388,12 @@ permissions:
scopenet.command.guild.market.sell: true
scopenet.command.guild.market.buy: true
scopenet.command.guild.pay: true
scopenet.command.guild.invite: true
scopenet.command.guild.accept: true
scopenet.command.guild.decline: true
scopenet.command.sell.hand: true
scopenet.command.market.sell: true
scopenet.command.market.point: true
scopenet.command.market.buy: true
scopenet.command.economy.*:
description: All economy commands
+3 -96
View File
@@ -1,12 +1,12 @@
{
"name": "scopenet-launcher",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scopenet-launcher",
"version": "0.4.0",
"version": "0.5.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -15,29 +15,19 @@
"@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.6.0",
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
"@tauri-apps/plugin-opener": "^2.6.0"
},
"devDependencies": {
"@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "~5.9.0",
"vite": "^8.3.1"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
@@ -700,25 +690,6 @@
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@thoughts-on-things/vantage-mc": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@thoughts-on-things/vantage-mc/-/vantage-mc-0.15.1.tgz",
"integrity": "sha512-muhg9950CgOa8keoCUV+utDHUKhkckwWGVn5O3HFMrgr7OrPo3VpEsBYVSUVL/RjmQDiqEaC19FPLGMTH97jpg==",
"license": "MIT",
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"three": ">=0.160"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@tsconfig/svelte": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -726,13 +697,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -740,43 +704,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -906,13 +833,6 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1216,13 +1136,6 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"dev": true,
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1448,12 +1361,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2 -5
View File
@@ -1,7 +1,7 @@
{
"name": "scopenet-launcher",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,16 +18,13 @@
"@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.6.0",
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
"@tauri-apps/plugin-opener": "^2.6.0"
},
"devDependencies": {
"@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "~5.9.0",
+156 -96
View File
@@ -150,6 +150,16 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
Ok(options)
}
#[tauri::command]
pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f64) -> Res<()> {
if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() {
return Err("FOV must be between 30 and 110 degrees".into());
}
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default()
.insert("fov".into(), format!("{fov:.3}"));
state.save_settings().map_err(aerr)
}
// ---- accounts ----
#[tauri::command]
@@ -625,111 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
if !resp.status().is_success() {
return Err("unable to load servers".into());
}
let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
#[derive(Serialize)]
pub struct MapResource {
status: u16,
data: Option<String>,
etag: Option<String>,
}
/// Native transport for Vantage assets. Browser WebViews cannot fetch a
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
/// Only paths under a map URL supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&configured).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.join(".").map_err(err)?;
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured Vantage world".into());
}
fetch_map_asset(target, etag, None).await
}
/// What the launcher needs to offer a Live Map button for one game server.
#[tauri::command]
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
}
resp.json().await.map_err(err)
}
/// Native transport for the panel-hosted live map. The session token is
/// attached here, so it never reaches page scripts, and only URLs under
/// this panel's `/api/livemap/<server>/` can be fetched.
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
/// because the map loads them straight from the panel as images).
#[tauri::command]
pub async fn fetch_livemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let token = accounts::panel_token(&state).ok_or("sign in to view the live map")?;
let base = reqwest::Url::parse(&format!("{}/api/livemap/{server_id}/", panel.trim_end_matches('/'))).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
if target.origin() != base.origin()
|| !target.path().starts_with(base.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside this server's live map".into());
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
}
fetch_map_asset(target, etag, Some(token)).await
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
}
Ok(info)
}
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
use base64::Engine;
if target.as_str().len() > 2048 {
return Err("map asset URL is too long".into());
/// Players now, plus the claims and pins the game server last sent.
#[tauri::command]
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(err)?;
let mut request = client.get(target);
if let Some(token) = bearer {
request = request.bearer_auth(token);
}
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
}
let response = request.send().await.map_err(err)?;
let status = response.status().as_u16();
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
if status == 304 {
return Ok(MapResource { status, data: None, etag });
}
if !response.status().is_success() {
return Ok(MapResource { status, data: None, etag });
}
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
return Err("Vantage asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 {
return Err("Vantage asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
@@ -746,6 +682,17 @@ pub async fn get_my_level(state: State<'_, AppState>) -> Res<UserLevelInfo> {
resp.json().await.map_err(err)
}
/// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:<id>`.
#[tauri::command]
pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/reward-bundles").await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
return Ok(serde_json::json!({}));
}
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn get_my_quests(state: State<'_, AppState>) -> Res<Vec<UserQuest>> {
let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?;
@@ -807,6 +754,71 @@ pub async fn get_my_guild(state: State<'_, AppState>, instance_id: Option<String
resp.json().await.map_err(err)
}
async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
body["error"].as_str().unwrap_or(fallback).to_string()
}
#[tauri::command]
pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?;
let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); }
Ok(())
}
#[tauri::command]
pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); }
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?;
let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); }
Ok(())
}
#[tauri::command]
pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); }
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> {
let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); }
Ok(())
}
#[tauri::command]
pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> {
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")).await?.json(&serde_json::json!({"role":role})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); }
Ok(())
}
#[tauri::command]
pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> {
let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); }
Ok(())
}
#[tauri::command]
pub async fn update_guild(state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String) -> Res<()> {
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")).await?
.json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url}))
.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); }
Ok(())
}
#[tauri::command]
pub async fn create_guild(
state: State<'_, AppState>,
@@ -1190,3 +1202,51 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
let body: serde_json::Value = resp.json().await.map_err(err)?;
Ok(body["liked"].as_bool().unwrap_or(false))
}
// ---------------------------------------------------------------------------
// Map actions and guild invitations
// ---------------------------------------------------------------------------
async fn ok_or_panel_error(resp: reqwest::Response, fallback: &str) -> Res<serde_json::Value> {
if resp.status().is_success() {
return Ok(resp.json().await.unwrap_or(serde_json::Value::Null));
}
let body: serde_json::Value = resp.json().await.unwrap_or_default();
Err(body["error"].as_str().or(body["message"].as_str()).unwrap_or(fallback).to_string())
}
/// From the map: ask a player to teleport to, or send them a message in game.
#[tauri::command]
pub async fn map_action(state: State<'_, AppState>, server_id: i64, action: String, target_uuid: String, text: Option<String>) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/servers/{server_id}/map/actions")).await?;
let resp = req
.json(&serde_json::json!({ "action": action, "target_uuid": target_uuid, "text": text.unwrap_or_default() }))
.send()
.await
.map_err(err)?;
ok_or_panel_error(resp, "unable to do that right now").await.map(|_| ())
}
/// Invite a player to a guild you lead or officiate.
#[tauri::command]
pub async fn guild_send_invite(state: State<'_, AppState>, guild_id: String, target_uuid: String) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/invites")).await?;
let resp = req.json(&serde_json::json!({ "uuid": target_uuid })).send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to send the invitation").await
}
#[tauri::command]
pub async fn my_guild_invites(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/guilds/invites").await?;
let resp = req.send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to load invitations").await
}
#[tauri::command]
pub async fn respond_guild_invite(state: State<'_, AppState>, invite_id: i64, accept: bool) -> Res<serde_json::Value> {
let path = format!("/guilds/invites/{invite_id}/{}", if accept { "accept" } else { "decline" });
let req = account_api(&state, reqwest::Method::POST, &path).await?;
let resp = req.send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to answer the invitation").await
}
+3
View File
@@ -129,6 +129,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
} else {
current_options
};
if let Some(fov) = settings.instance_game_options.get(&instance_id).and_then(|o| o.get("fov")) {
saved_options.insert("fov".into(), fov.clone());
}
// Do not rewrite pack files or options while another copy is using this directory.
if !already_running {
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
+16 -3
View File
@@ -52,12 +52,17 @@ pub fn run() {
commands::set_panel_url,
commands::save_settings,
commands::save_instance_options,
commands::set_instance_fov,
commands::login_panel,
commands::discord_sign_in_start,
commands::discord_sign_in_poll,
commands::request_password_reset,
commands::discord_connection,
commands::discord_info,
commands::map_action,
commands::guild_send_invite,
commands::my_guild_invites,
commands::respond_guild_invite,
commands::discord_link_start,
commands::discord_unlink,
commands::register_panel,
@@ -92,15 +97,23 @@ pub fn run() {
commands::get_player_stats,
commands::get_leaderboard,
commands::get_public_servers,
commands::fetch_vantage_resource,
commands::fetch_livemap_resource,
commands::get_livemap_info,
commands::get_map_info,
commands::get_map_overlay,
commands::get_my_level,
commands::get_my_quests,
commands::get_reward_summaries,
commands::claim_quest,
commands::get_my_achievements,
commands::get_guilds,
commands::get_my_guild,
commands::request_guild_join,
commands::get_guild_join_requests,
commands::respond_guild_join_request,
commands::get_guild_roles,
commands::create_guild_role,
commands::assign_guild_role,
commands::delete_guild_role,
commands::update_guild,
commands::create_guild,
commands::get_guild_claims,
commands::claim_guild_chunk,
+8 -6
View File
@@ -4,15 +4,20 @@ use crate::settings::Settings;
use scopenet_core::Layout;
use scopenet_shared::LauncherManifest;
use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
pub mod build {
/// Default panel URL for this build.
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") {
Some(u) if !u.is_empty() => Some(u),
_ => Some("https://scopenetmcpanel.scopedd.lol"),
};
/// `owner/repo` used to check GitHub releases for updates.
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
pub const REPO: Option<&str> = match option_env!("SCOPENET_REPO") {
Some(r) if !r.is_empty() => Some(r),
_ => Some("scopeddlol/SCOPENET-MC"),
};
/// When set to "1", players can't change the panel URL.
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
@@ -35,8 +40,6 @@ pub struct AppState {
pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>,
/// Vantage URLs received from the panel, keyed by linked game server.
pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Vec<RunningGame>>,
@@ -55,7 +58,6 @@ impl AppState {
settings: RwLock::new(settings),
accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest),
map_urls: Mutex::new(HashMap::new()),
task: Mutex::new(None),
game: Mutex::new(Vec::new()),
data_dir,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SCOPENET Launcher",
"version": "0.4.0",
"version": "0.5.0",
"identifier": "net.scopenet.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -27,7 +27,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; frame-src https: http:; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
+14 -1
View File
@@ -40,10 +40,17 @@ html, body {
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */
overflow-wrap: anywhere;
}
#app { height: 100%; }
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
*, *::before, *::after { min-width: 0; }
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
@@ -85,8 +92,11 @@ button {
font-weight: 520;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
white-space: nowrap;
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
white-space: normal;
max-width: 100%;
}
button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; }
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
@@ -154,3 +164,6 @@ label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } }
code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; }
pre { white-space: pre-wrap; }
+121 -475
View File
@@ -1,524 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, RefreshCw, Server
} from '@lucide/svelte';
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs, toast } from '../lib/store.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
import ClaimTerrain from './ClaimTerrain.svelte';
let {
instanceId,
myGuildId = null,
myGuildTag = '',
onclaim,
onunclaim
}: {
instanceId: string;
myGuildId?: string | null;
myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void;
onunclaim?: (claimId: number) => void;
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
} = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]);
type GameServer = { id: number; name: string; instance_id: string; map?: boolean };
type Player = { x: number; z: number; dimension: string };
let servers = $state<GameServer[]>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
let centerZ = $state(0);
let radius = $state(5); // 11x11 grid
let centerX = $state(0), centerZ = $state(0);
let player = $state<Player | null>(null);
let claims = $state<GuildClaim[]>([]);
let loading = $state(false);
let claiming = $state(false);
let loading = $state(false), painting = $state(false);
let claimLoad = 0;
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
let selectedChunk = $state<{ x: number; z: number } | null>(null);
let mapDimensions = $state<string[]>([]);
let mapFailed = $state(false);
// The panel's central live map (if switched on for this server) wins over an external Vantage URL.
type LiveInfo = { ready: boolean; base: string; dimensions: { id: string; available: boolean }[] };
let liveInfo = $state<LiveInfo | null>(null);
let centeredServer = 0;
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const selectedServer = $derived(servers.find((s) => s.id === serverId));
$effect(() => {
const id = selectedServer?.live_map ? selectedServer.id : null;
liveInfo = null;
if (!id) return;
let alive = true;
invoke<LiveInfo>('get_livemap_info', { serverId: id }).then((i) => { if (alive) liveInfo = i; }).catch(() => {});
return () => { alive = false; };
});
const central = $derived(!!liveInfo?.ready);
const dimSlug = $derived(dimension.replace('minecraft:', ''));
const mapUrl = $derived(central && liveInfo ? abs(`${liveInfo.base}/${dimSlug}/v1/worlds/default/manifest.json`) ?? '' : selectedServer?.map_url || '');
const dimDisabled = (id: string) => central
? !liveInfo?.dimensions.find((d) => d.id === id)?.available
: !!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(id);
$effect(() => { mapUrl; mapFailed = false; if (!central) mapDimensions = []; });
const selectedServer = $derived(instanceServers.find((s) => s.id === serverId));
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
{ id: 'minecraft:the_nether', label: 'Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'End', icon: Compass }
] as const;
const myClaimsCount = $derived(claims.filter((c) => c.guild_id === myGuildId).length);
async function refreshPosition(id: number) {
try {
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
if (serverId !== id) return;
const mine = activeAccount()?.uuid?.toLowerCase();
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
if (player && centeredServer !== id) {
centeredServer = id;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
} catch { player = null; }
}
async function loadClaims() {
const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; }
if (!serverId || !instanceId) { claims = []; return; }
loading = true;
claims = [];
try {
const next = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId,
serverId,
centerX,
centerZ,
dimension
});
const next = await invoke<GuildClaim[]>('get_guild_claims', { instanceId, serverId, dimension, centerX, centerZ });
if (request === claimLoad) claims = next;
} catch (e: any) {
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error');
} finally {
if (request === claimLoad) loading = false;
}
if (request === claimLoad) toast(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
} finally { if (request === claimLoad) loading = false; }
}
function getClaimAt(x: number, z: number): GuildClaim | undefined {
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
}
const selectedClaim = $derived(
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
);
const myClaimsCount = $derived(
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
);
async function handleClaim() {
if (!selectedChunk || !myGuildId || !serverId) return;
claiming = true;
async function paintChunks(chunks: { x: number; z: number }[], mode: 'claim' | 'unclaim') {
if (!serverId || !myGuildId || painting) return;
const existing = new Map(claims.map((c) => [`${c.chunk_x}:${c.chunk_z}`, c]));
const wanted = chunks.filter(({ x, z }) => {
const current = existing.get(`${x}:${z}`);
return mode === 'claim' ? !current : current?.guild_id === myGuildId;
});
if (!wanted.length) return;
painting = true;
let done = 0;
try {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId,
instanceId,
serverId,
dimension,
chunkX: selectedChunk.x,
chunkZ: selectedChunk.z
});
claims = [...claims, claim];
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
onclaim?.(claim);
} catch (e: any) {
toast(e?.message ?? 'Failed to claim chunk', 'error');
} finally {
claiming = false;
}
for (const chunk of wanted) {
try {
if (mode === 'claim') {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId, instanceId, serverId, dimension, chunkX: chunk.x, chunkZ: chunk.z
});
claims = [...claims, claim];
onclaim?.(claim);
} else {
const claim = existing.get(`${chunk.x}:${chunk.z}`)!;
await invoke('unclaim_guild_chunk', { claimId: claim.id });
claims = claims.filter((c) => c.id !== claim.id);
onunclaim?.(claim.id);
}
done++;
} catch (e: any) {
toast(`${done} of ${wanted.length} chunks updated. ${typeof e === 'string' ? e : e?.message ?? 'Claim failed'}`, 'error');
break;
}
}
if (done === wanted.length) toast(`${done} ${done === 1 ? 'chunk' : 'chunks'} ${mode === 'claim' ? 'claimed' : 'unclaimed'}`, 'ok');
} finally { painting = false; await loadClaims(); }
}
async function handleUnclaim() {
if (!selectedClaim) return;
claiming = true;
try {
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
claims = claims.filter((c) => c.id !== selectedClaim.id);
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
onunclaim?.(selectedClaim.id);
selectedChunk = null;
} catch (e: any) {
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
} finally {
claiming = false;
}
function centerOnPlayer() {
if (!player) return;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
function pan(dx: number, dz: number) {
centerX += dx;
centerZ += dz;
}
function resetCenter() {
centerX = 0;
centerZ = 0;
}
$effect(() => {
if (instanceId && dimension && serverId) {
loadClaims();
}
});
onMount(async () => {
const result = await invoke<{ servers: typeof servers }>('get_public_servers');
servers = result.servers;
onMount(() => {
invoke<{ servers: GameServer[] }>('get_public_servers').then((r) => { servers = r.servers; })
.catch((e: any) => { toast(typeof e === 'string' ? e : 'Could not load servers', 'error'); });
const timer = setInterval(() => { if (serverId) refreshPosition(serverId); }, 10_000);
return () => clearInterval(timer);
});
$effect(() => {
const available = servers.filter(s => s.instance_id === instanceId);
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null;
const available = instanceServers;
if (!available.some((s) => s.id === serverId)) serverId = available[0]?.id ?? null;
});
$effect(() => { if (serverId) { player = null; centeredServer = 0; refreshPosition(serverId); } });
$effect(() => { if (serverId && instanceId && dimension) loadClaims(); });
</script>
<div class="chunk-map-container glass">
<div class="claim-map glass">
<div class="toolbar">
<div class="tb-group">
<div class="group">
{#if instanceServers.length > 1}
<label class="server-pick" title="Game server"><Server size={14} />
<select bind:value={serverId} aria-label="Game server">
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{:else if instanceServers[0]}
<span class="chip server-chip"><Server size={13} /> {instanceServers[0].name}</span>
{/if}
<div class="dim-selector" role="tablist" aria-label="Dimension">
<label class="server"><ServerIcon size={14} /><select bind:value={serverId} aria-label="Game server">
{#each instanceServers as s}<option value={s.id}>{s.name}</option>{/each}
</select></label>
{:else if selectedServer}<span class="chip"><ServerIcon size={14} /> {selectedServer.name}</span>{/if}
<div class="dimensions" role="tablist" aria-label="Dimension">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
role="tab"
aria-selected={dimension === dim.id}
class="dim-btn"
class:active={dimension === dim.id}
disabled={dimDisabled(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{@const Icon = dim.icon}
<button role="tab" aria-selected={dimension === dim.id} class:active={dimension === dim.id}
onclick={() => (dimension = dim.id)}><Icon size={14} /> {dim.label}</button>
{/each}
</div>
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
</div>
<div class="tb-group">
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span>
<div class="pad">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan west" aria-label="Pan west"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan north" aria-label="Pan north"><ArrowUp size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Back to the world origin" aria-label="Centre on origin"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan south" aria-label="Pan south"><ArrowDown size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan east" aria-label="Pan east"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims">
<RefreshCw size={14} class={loading ? 'spin' : ''} />
</button>
<div class="group">
{#if player}<span class="position">You: {Math.floor(player.x)}, {Math.floor(player.z)}</span>{:else}<span class="position">Player position unavailable</span>{/if}
<button class="ghost sm" onclick={centerOnPlayer} disabled={!player} title="Center on your current position"><LocateFixed size={15} /> Find me</button>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
</div>
</div>
{#if !serverId}
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div>
{/if}
<!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
{#key `${serverId}:${mapUrl}`}
<VantageMap
world={mapUrl}
serverId={serverId!}
{central}
{claims}
{dimension}
{myGuildId}
onselect={(x, z) => { selectedChunk = { x, z }; }}
ondimension={(value) => { if (dimension !== value) { dimension = value; claims = []; selectedChunk = null; } }}
ondimensions={(value) => { mapDimensions = value; }}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }}
onerror={(message) => { toast(`Live map unavailable: ${message}. Showing chunk grid.`, 'error'); mapFailed = true; }}
/>
{#if selectedServer?.map}
{#key serverId}
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{claims} {myGuildId} {player} onpaint={paintChunks}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
{/key}
{:else}
<div class="grid-wrapper">
<div
class="chunk-grid"
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
>
{#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz}
{#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx}
{@const claim = getClaimAt(cx, cz)}
{@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId}
{@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)}
{@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz}
{@const isCenter = cx === 0 && cz === 0}
<button
class="chunk-cell"
class:my-claim={isMyClaim}
class:other-claim={isOtherClaim}
class:selected={isSelected}
class:is-center={isCenter}
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
onmouseleave={() => (hoveredChunk = null)}
onclick={() => (selectedChunk = { x: cx, z: cz })}
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
>
{#if isMyClaim}
<Shield size={16} class="claim-icon mine" />
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
{:else if isOtherClaim}
<Lock size={15} class="claim-icon rival" />
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
{:else if isCenter}
<span class="spawn-dot" title="World Origin"></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
{/if}
<!-- Legend & Action Bar -->
<div class="map-footer">
<div class="info-block">
{#if selectedChunk}
<div class="selected-details">
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
<span class="block-range muted tiny">
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
</span>
{#if selectedClaim}
<div class="claim-status">
{#if myGuildId && selectedClaim.guild_id === myGuildId}
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
<button
class="danger sm action-btn"
onclick={handleUnclaim}
disabled={claiming}
>
<Trash2 size={13} /> Unclaim
</button>
{:else}
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
<span class="tiny muted protected-note">Grief Protection Active</span>
{/if}
</div>
{:else}
<div class="unclaimed-status">
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
{#if myGuildId}
<button
class="primary sm action-btn"
onclick={handleClaim}
disabled={claiming}
>
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
Claim Chunk
</button>
{:else}
<span class="tiny muted">Join or create a guild to claim land</span>
{/if}
</div>
{/if}
</div>
{:else if hoveredChunk}
<div class="hover-info">
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
<span class="muted tiny">Click to view details or claim</span>
</div>
{:else}
<div class="hint-info muted tiny">
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
</div>
{/if}
</div>
<div class="legend">
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
</div>
<div class="footer">
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
{#if painting}<span>Updating claims…</span>{/if}
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
</div>
</div>
<style>
.chunk-map-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 80%, transparent);
border: 1px solid var(--line);
user-select: none;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; }
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.server-pick { position: relative; display: inline-flex; align-items: center; }
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
.server-pick select { width: auto; min-width: 10rem; padding: 0.4rem 2rem 0.4rem 2rem; font-size: 0.82rem; font-weight: 560; border-radius: 99rem; }
.server-chip { padding: 0.4rem 0.8rem; border-radius: 99rem; font-size: 0.82rem; }
.live-chip { border-radius: 99rem; color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.pulse { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent); animation: pulse 2s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 0.4rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.dim-selector { display: flex; background: color-mix(in srgb, var(--bg) 60%, transparent); padding: 0.2rem; border-radius: 99rem; border: 1px solid var(--line); gap: 0.15rem; }
.dim-btn { display: flex; align-items: center; gap: 0.35rem; background: transparent; border: none; padding: 0.3rem 0.8rem; font-size: 0.8rem; font-weight: 560; color: var(--muted); border-radius: 99rem; transition: all 0.15s; }
.dim-btn:hover:not(:disabled) { color: var(--text); }
.dim-btn.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
.coord-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.pad { display: inline-flex; padding: 0.15rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 60%, transparent); }
.notice { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); font-size: 0.85rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); }
.grid-wrapper {
overflow: auto;
display: flex;
justify-content: center;
padding: 0.5rem;
background: color-mix(in srgb, var(--bg) 80%, black);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
max-height: 24rem;
}
.chunk-grid {
display: grid;
gap: 2px;
}
.chunk-cell {
width: 38px;
height: 38px;
padding: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.chunk-cell:hover {
transform: scale(1.08);
z-index: 10;
border-color: var(--text);
background: color-mix(in srgb, var(--text) 12%, transparent);
}
.chunk-cell.selected {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent);
z-index: 15;
}
.chunk-cell.is-center::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: #f59e0b;
}
.chunk-cell.my-claim {
background: color-mix(in srgb, var(--success) 22%, transparent);
border-color: color-mix(in srgb, var(--success) 70%, transparent);
}
.chunk-cell.other-claim {
background: color-mix(in srgb, var(--danger) 22%, transparent);
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
}
:global(.claim-icon.mine) {
color: var(--success);
}
:global(.claim-icon.rival) {
color: var(--danger);
}
.cell-tag {
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
margin-top: 1px;
max-width: 34px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
}
.map-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
}
.info-block {
flex: 1;
min-width: 14rem;
}
.selected-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.chunk-title {
font-size: 0.88rem;
}
.claim-status, .unclaimed-status {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
border-radius: 99rem;
}
.badge.mine {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--success);
}
.badge.rival {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.badge.unclaimed {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--muted);
}
.protected-note {
font-style: italic;
}
.legend {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.76rem;
color: var(--muted);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-box {
width: 12px;
height: 12px;
border-radius: 3px;
border: 1px solid transparent;
}
.legend-box.mine {
background: color-mix(in srgb, var(--success) 35%, transparent);
border-color: var(--success);
}
.legend-box.rival {
background: color-mix(in srgb, var(--danger) 35%, transparent);
border-color: var(--danger);
}
.legend-box.wild {
background: color-mix(in srgb, var(--surface) 80%, transparent);
border-color: var(--line);
}
.claim-map { display: flex; flex-direction: column; gap: 0.8rem; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius); }
.toolbar, .group, .footer, .server, .chip, .legend { display: flex; align-items: center; gap: 0.6rem; }
.toolbar, .footer { justify-content: space-between; flex-wrap: wrap; }
.group { flex-wrap: wrap; }
.server select { width: auto; min-width: 10rem; }
.dimensions { display: flex; gap: 0.2rem; padding: 0.2rem; background: color-mix(in srgb, var(--bg) 70%, transparent); border-radius: 99px; }
.dimensions button { display: flex; gap: 0.3rem; align-items: center; border: 0; border-radius: 99px; background: transparent; color: var(--muted); padding: 0.35rem 0.65rem; font-size: 0.8rem; }
.dimensions button.active { background: color-mix(in srgb, var(--accent) 24%, transparent); color: var(--text); }
.position, .footer, .notice { color: var(--muted); font-size: 0.8rem; }
.notice { min-height: 420px; display: grid; place-items: center; text-align: center; border-radius: 10px; background: #142333; padding: 1rem; }
.footer { border-top: 1px solid var(--line); padding-top: 0.65rem; }
.footer span { display: inline-flex; align-items: center; gap: 0.3rem; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.legend .mine { background: #4ade80; }.legend .other { background: #fb7185; }
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '../lib/tauri';
import type { GuildClaim } from '../lib/types';
type Point = { x: number; z: number };
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
serverId: number; dimension: string; centerX: number; centerZ: number;
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
oncenterchange: (x: number, z: number) => void;
} = $props();
const TILE = 256;
let host: HTMLDivElement;
let width = $state(800), height = $state(480);
let pixelsPerBlock = $state(3);
let worldX = $state(8), worldZ = $state(8);
let info = $state<{ ready: boolean; message: string; tile_base: string; token: string | null; token_ttl_secs: number; dimensions: Array<{ slug: string; bounds: { min_x: number; max_x: number; min_y: number; max_y: number } | null }> } | null>(null);
let error = $state('');
let loading = $state(true);
let paint = $state<Point[]>([]);
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
let broken = $state<Record<string, true>>({});
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? null);
// Tile keys last an hour; renew well before they expire.
$effect(() => {
const id = serverId;
let alive = true, timer: ReturnType<typeof setTimeout>;
loading = true; error = ''; broken = {};
const load = async () => {
try {
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
if (!alive) return;
info = next; loading = false; error = next.ready ? '' : next.message;
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
} catch (e) {
if (!alive) return;
loading = false; error = typeof e === 'string' ? e : (e as Error)?.message ?? 'Could not load the map';
timer = setTimeout(load, 15000);
}
};
load();
return () => { alive = false; clearTimeout(timer); };
});
const tileUrl = (x: number, y: number) => `${info!.tile_base}/${slug}/0/${x}/${y}.png?t=${info!.token}`;
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
const left = $derived(worldX - width / (2 * pixelsPerBlock));
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
const visibleTiles = $derived.by(() => {
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
const out = [];
const b = here.bounds;
const minX = Math.floor(left / TILE), maxX = Math.floor((left + width / pixelsPerBlock) / TILE);
const minZ = Math.floor(top / TILE), maxZ = Math.floor((top + height / pixelsPerBlock) / TILE);
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
const url = tileUrl(x, z);
if (broken[url]) continue;
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
}
return out;
});
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
const playerHere = $derived(player?.dimension === dimension ? player : null);
function at(e: PointerEvent): Point {
const rect = host.getBoundingClientRect();
return { x: Math.floor((left + (e.clientX - rect.left) / pixelsPerBlock) / 16),
z: Math.floor((top + (e.clientY - rect.top) / pixelsPerBlock) / 16) };
}
function addStroke(next: Point) {
const last = pointer?.last ?? next;
const steps = Math.max(Math.abs(next.x - last.x), Math.abs(next.z - last.z));
const nextPaint = new Map(paint.map((p) => [`${p.x}:${p.z}`, p]));
for (let i = 0; i <= steps; i++) {
const x = Math.round(last.x + (next.x - last.x) * i / Math.max(1, steps));
const z = Math.round(last.z + (next.z - last.z) * i / Math.max(1, steps));
if (nextPaint.size < 128) nextPaint.set(`${x}:${z}`, { x, z });
}
paint = [...nextPaint.values()];
if (pointer) pointer.last = next;
}
function down(e: PointerEvent) {
if (!info?.ready) return;
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
e.preventDefault();
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
pointer = { id: e.pointerId, mode, x: e.clientX, y: e.clientY, last: null };
host.setPointerCapture(e.pointerId);
if (mode !== 'pan') { paint = []; addStroke(at(e)); }
}
function move(e: PointerEvent) {
if (!pointer || pointer.id !== e.pointerId) return;
if (pointer.mode === 'pan') {
worldX -= (e.clientX - pointer.x) / pixelsPerBlock;
worldZ -= (e.clientY - pointer.y) / pixelsPerBlock;
pointer.x = e.clientX; pointer.y = e.clientY;
} else addStroke(at(e));
}
function up(e: PointerEvent) {
if (!pointer || pointer.id !== e.pointerId) return;
const mode = pointer.mode;
if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
pointer = null;
if (mode === 'pan') oncenterchange(Math.floor(worldX / 16), Math.floor(worldZ / 16));
else if (paint.length) onpaint(paint, mode);
paint = [];
}
function wheel(e: WheelEvent) {
e.preventDefault();
pixelsPerBlock = Math.max(1.5, Math.min(6, pixelsPerBlock * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
}
onMount(() => {
const observer = new ResizeObserver(([entry]) => { width = entry.contentRect.width; height = entry.contentRect.height; });
observer.observe(host);
return () => observer.disconnect();
});
</script>
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="Territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
{#each visibleTiles as tile (tile.key)}
<img class="tile" src={tile.url} alt="" draggable="false" onerror={() => (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />
{/each}
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
{#each visibleClaims as claim (claim.id)}
<div class="claim" class:mine={claim.guild_id === myGuildId}
title="[{claim.chunk_x}, {claim.chunk_z}] {claim.guild_name}"
style:left="{(claim.chunk_x * 16 - left) * pixelsPerBlock}px" style:top="{(claim.chunk_z * 16 - top) * pixelsPerBlock}px"
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
{/each}
{#each paint as chunk (`${chunk.x}:${chunk.z}`)}
<div class="preview" class:remove={pointer?.mode === 'unclaim'}
style:left="{(chunk.x * 16 - left) * pixelsPerBlock}px" style:top="{(chunk.z * 16 - top) * pixelsPerBlock}px"
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
{/each}
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
{#if loading}<div class="message">Loading the map…</div>{/if}
{#if error && !loading}<div class="message">{error}</div>{/if}
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
</div>
<style>
.terrain { position: relative; height: min(62vh, 640px); min-height: 420px; overflow: hidden; background: #142333; border-radius: 10px; cursor: crosshair; touch-action: none; }
.tile { position: absolute; image-rendering: pixelated; pointer-events: none; }
.grid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, #ffffff42 1px, transparent 1px), linear-gradient(to bottom, #ffffff42 1px, transparent 1px); }
.claim, .preview { position: absolute; pointer-events: none; box-sizing: border-box; }
.claim { background: #fb71854a; border: 2px solid #fb7185; }
.claim.mine { background: #4ade804a; border-color: #4ade80; }
.preview { background: #4ade8090; border: 2px solid #fff; }
.preview.remove { background: #fb718590; }
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
.help { left: 10px; }.coords { right: 10px; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from './PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
@@ -213,7 +214,7 @@
</span>
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
<div class="who">
<strong>{m?.name ?? 'Former member'}</strong>
<strong>{#if m}<PlayerLink uuid={tx.actor_uuid} name={m.name} />{:else}Former member{/if}</strong>
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
</div>
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
@@ -71,6 +71,15 @@
<div class="card col">
<strong>Vanilla game settings</strong>
<span class="help">Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates.</span>
<label class="field">
<span class="row">Field of view <span class="spacer"></span><strong>{Math.round(70 + 40 * Number(app.settings.instance_game_options?.[inst.id]?.fov ?? '0'))}°</strong></span>
<input type="range" min="-1" max="1" step="0.025" value={app.settings.instance_game_options?.[inst.id]?.fov ?? '0'} oninput={(e) => {
app.settings!.instance_game_options[inst.id] ??= {};
app.settings!.instance_game_options[inst.id].fov = e.currentTarget.value;
invoke('set_instance_fov', { instanceId: inst.id, fov: Number(e.currentTarget.value) }).catch((err) => toast(errorText(err), 'error'));
}} />
<span class="help">Applied the next time you launch this instance.</span>
</label>
<span class="tiny muted">{Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance</span>
<button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
try {
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, Globe, Flame, Sparkles, Users, LoaderCircle, RefreshCw, MapPinOff } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs } from '../lib/store.svelte';
import VantageMap from './VantageMap.svelte';
let { serverId, serverName, onclose }: { serverId: number; serverName: string; onclose: () => void } = $props();
type Info = {
enabled: boolean;
ready: boolean;
message: string;
base: string;
players: number;
dimensions: { id: string; slug: string; label: string; available: boolean }[];
};
let info = $state<Info | null>(null);
let slug = $state('overworld');
let error = $state('');
let failed = $state('');
let loaded = $state(false);
const icons: Record<string, any> = { overworld: Globe, the_nether: Flame, the_end: Sparkles };
const url = $derived(info?.ready ? abs(`${info.base}/${slug}/v1/worlds/default/manifest.json`) : null);
async function refresh() {
try {
info = await invoke<Info>('get_livemap_info', { serverId });
error = '';
if (!info.dimensions.find((d) => d.slug === slug)?.available) slug = info.dimensions.find((d) => d.available)?.slug ?? 'overworld';
} catch (e: any) {
error = typeof e === 'string' ? e : e?.message ?? 'Unable to reach the panel';
}
}
onMount(() => {
refresh();
// While the map is still being prepared, keep checking; afterwards only the roster count changes.
const t = setInterval(refresh, 15_000);
return () => clearInterval(t);
});
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={info?.ready}></span>
<div>
<strong>{serverName}</strong>
<span class="muted">Live Map{info?.ready ? ` · ${info.players} ${info.players === 1 ? 'player' : 'players'} online` : ''}</span>
</div>
</div>
{#if info?.ready}
<nav class="dims">
{#each info.dimensions as d (d.slug)}
{@const Icon = icons[d.slug] ?? Globe}
<button class="dim" class:on={slug === d.slug} disabled={!d.available} onclick={() => { slug = d.slug; failed = ''; loaded = false; }}>
<Icon size={14} /> {d.label}
</button>
{/each}
</nav>
{/if}
{#if info?.ready}<span class="chip players"><Users size={13} /> {info.players}</span>{/if}
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#if url && !failed}
{#key `${serverId}:${slug}`}
<VantageMap
world={url}
{serverId}
central
fill
onready={() => (loaded = true)}
onerror={(m) => (failed = m)}
/>
{/key}
{#if !loaded}<div class="note"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
{:else}
<div class="note card">
{#if failed}
<MapPinOff size={30} />
<h3>Couldn't load the map</h3>
<p class="muted selectable">{failed}</p>
<button class="primary" onclick={() => { failed = ''; loaded = false; refresh(); }}><RefreshCw size={15} /> Try again</button>
{:else if error}
<MapPinOff size={30} />
<h3>Live Map unavailable</h3>
<p class="muted selectable">{error}</p>
<button class="primary" onclick={refresh}><RefreshCw size={15} /> Try again</button>
{:else if info}
<LoaderCircle class="spin" size={30} />
<h3>{info.enabled ? 'The map is being prepared' : 'Live Map is off'}</h3>
<p class="muted">{info.message || 'Hang on…'}</p>
<p class="tiny muted">This page checks again automatically.</p>
{:else}
<LoaderCircle class="spin" size={28} />
{/if}
</div>
{/if}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 1rem; padding: 0.65rem 0.8rem 0.65rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.75rem; min-width: 0; margin-right: auto; }
.title div { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.title strong { font-size: 0.98rem; font-weight: 620; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title .muted { font-size: 0.78rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 50%, transparent); flex-shrink: 0; }
.live.on { background: var(--success); box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--success) 22%, transparent); }
.dims { display: flex; gap: 0.3rem; padding: 0.2rem; border-radius: 99px; background: color-mix(in srgb, var(--text) 6%, transparent); }
.dim { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.85rem; border-radius: 99px; font-size: 0.82rem; border: 0; background: transparent; color: var(--muted); }
.dim:hover:not(:disabled) { color: var(--text); }
.dim.on { background: var(--accent); color: #fff; }
.dim:disabled { opacity: 0.35; }
.players { display: inline-flex; align-items: center; gap: 0.35rem; }
.close { flex-shrink: 0; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #102026; }
.stage :global(.vantage-map) { position: absolute; inset: 0; }
.note { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; max-width: 26rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 1.6rem 2rem; text-align: center; color: #e5e7eb; }
.stage > .note:not(.card) { inset: auto 1rem 1rem auto; margin: 0; flex-direction: row; padding: 0.55rem 0.9rem; border-radius: 99px; background: rgba(9, 18, 22, 0.8); font-size: 0.85rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); color: var(--text); }
.card h3 { font-size: 1.05rem; }
</style>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, RefreshCw } from '@lucide/svelte';
import MapViewer from '@scopenet/map/MapViewer.svelte';
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
import { invoke } from '../lib/tauri';
import { activeAccount, app } from '../lib/store.svelte';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, instanceId, onclose }: { serverId: number; serverName: string; instanceId: string; onclose: () => void } = $props();
let info = $state<MapInfo | null>(null);
let overlay = $state<MapOverlay | null>(null);
let error = $state('');
let nonce = $state(0);
const panel = $derived(activeAccount()?.panel_url ?? app.panelUrl);
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
const avatarUrl = (uuid: string) => (panel ? `${panel}/api/v1/avatar/${uuid}?size=64&v=${app.skinVersion}` : '');
// The feed reloads the overlay every couple of seconds and renews the tile key before it expires.
onMount(() => startFeed({
loadInfo: () => invoke<MapInfo>('get_map_info', { serverId }),
loadOverlay: () => invoke<MapOverlay>('get_map_overlay', { serverId }),
onInfo: (i) => { info = i; error = ''; },
onOverlay: (o) => { overlay = o; error = ''; },
onError: (m) => { error = m; }
}));
const reload = () => { info = null; overlay = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={!!info?.ready}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map{#if overlay} · {overlay.players.length} online{/if}</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error} selfUuid={activeAccount()?.uuid ?? null}>
{#snippet player(p: LivePlayer, close: () => void)}
<MapPlayerPanel {serverId} {instanceId} uuid={p.uuid} nameHint={p.name} onclose={close} />
{/snippet}
</MapViewer>
{/key}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; --map-accent: var(--accent); }
</style>
@@ -0,0 +1,165 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, Send, UserPlus, Sparkles, MapPin, LoaderCircle, Shield, Clock, Swords, Star } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import PlayerLink from './PlayerLink.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, UserProfileView } from '../lib/types';
let { serverId, instanceId, uuid, nameHint, onclose }: { serverId: number; instanceId: string; uuid: string; nameHint: string; onclose: () => void } = $props();
let profile = $state<UserProfileView | null>(null);
let loading = $state(true);
let failed = $state('');
let message = $state('');
let busy = $state('');
let myGuild = $state<Guild | null>(null);
let iCanInvite = $state(false);
const me = $derived(activeAccount());
// Acting on someone needs a panel account; offline accounts can still look.
const signedIn = $derived(me?.kind === 'panel');
const isMe = $derived(me?.uuid?.toLowerCase() === uuid.toLowerCase());
async function load() {
loading = true;
failed = '';
try {
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
} catch (e: any) {
failed = typeof e === 'string' ? e : e?.message ?? 'Could not load this player';
} finally {
loading = false;
}
if (signedIn) {
try {
myGuild = await invoke<Guild | null>('get_my_guild', { instanceId });
if (myGuild) {
const members = await invoke<GuildMember[]>('get_guild_members', { guildId: myGuild.id });
const mine = members.find((m) => m.uuid.toLowerCase() === me?.uuid?.toLowerCase());
iCanInvite = mine?.role === 'leader' || mine?.role === 'officer';
}
} catch { /* invites just stay off */ }
}
}
// A new click on the map loads the new player.
$effect(() => { void uuid; message = ''; load(); });
onMount(() => { const t = setInterval(() => { if (!busy) load(); }, 20000); return () => clearInterval(t); });
async function run(kind: string, job: () => Promise<unknown>, done: string) {
busy = kind;
try { await job(); toast(done, 'ok'); }
catch (e: any) { toast(typeof e === 'string' ? e : e?.message ?? 'That did not work', 'error'); }
finally { busy = ''; }
}
const tpa = () => run('tpa', () => invoke('map_action', { serverId, action: 'tpa', targetUuid: uuid, text: null }), 'Teleport request sent');
const invite = () => run('invite', () => invoke('guild_send_invite', { guildId: myGuild!.id, targetUuid: uuid }), `Invitation sent to ${profile?.username ?? nameHint}`);
async function send() {
const text = message.trim();
if (!text) return;
await run('message', () => invoke('map_action', { serverId, action: 'message', targetUuid: uuid, text }), 'Message sent');
message = '';
}
const hours = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m`);
const name = $derived(profile?.username ?? nameHint ?? 'Player');
const online = $derived(!!profile?.online);
const kd = $derived.by(() => {
const k = Number(profile?.stats?.player_kills ?? 0), d = Number(profile?.stats?.deaths ?? 0);
return d === 0 ? (k ? `${k}.0` : '–') : (k / d).toFixed(2);
});
const canAct = $derived(signedIn && !isMe && online);
</script>
<aside class="panel glass" style:--pa={profile?.accent_color || 'var(--accent)'} aria-label="Player">
<button class="ghost icon close" onclick={onclose} aria-label="Close"><X size={16} /></button>
{#if loading && !profile}
<div class="center"><LoaderCircle class="spin" size={24} /></div>
{:else if failed && !profile}
<p class="muted pad">{failed}</p>
{:else if profile}
<div class="head">
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
<div class="who">
<h3><PlayerLink {uuid} {name} /></h3>
{#if profile.level_info?.title}<span class="title"><Sparkles size={12} /> {profile.level_info.title}</span>{/if}
<span class="presence"><i class:on={online}></i>{online ? 'Online now' : 'Offline'}</span>
</div>
</div>
<div class="chips">
<span class="chip lv"><Star size={12} /> Level {profile.level_info?.level ?? 1}</span>
{#if profile.guild}<span class="chip guild"><Shield size={12} /> [{profile.guild.tag}] {profile.guild.name}</span>{/if}
{#if profile.rank}<span class="chip rank">{profile.rank.display}</span>{/if}
</div>
<div class="facts">
<div><Clock size={13} /><span>Playtime</span><strong>{hours(profile.stats?.playtime_secs ?? 0)}</strong></div>
<div><Swords size={13} /><span>K/D</span><strong>{kd}</strong></div>
<div><MapPin size={13} /><span>Favourite</span><strong>{profile.favorite_server ?? '–'}</strong></div>
<div><Star size={13} /><span>Awards</span><strong>{profile.achievements_count ?? 0}</strong></div>
</div>
{#if profile.bio}<p class="bio">{profile.bio}</p>{/if}
<div class="actions">
{#if isMe}
<p class="muted small">This is you.</p>
{:else if !signedIn}
<p class="muted small">Sign in with a SCOPENET account to send requests and messages.</p>
{:else if !online}
<p class="muted small">{name} isn't online on this server, so they can't be reached from the map.</p>
{/if}
<button class="primary" disabled={!canAct || !!busy} onclick={tpa}>
{#if busy === 'tpa'}<LoaderCircle class="spin" size={14} />{:else}<MapPin size={14} />{/if} Send teleport request
</button>
<div class="msg">
<input bind:value={message} placeholder="Message in game…" maxlength="200" disabled={!canAct} onkeydown={(e) => e.key === 'Enter' && send()} />
<button disabled={!canAct || !message.trim() || !!busy} onclick={send} aria-label="Send message">
{#if busy === 'message'}<LoaderCircle class="spin" size={14} />{:else}<Send size={14} />{/if}
</button>
</div>
<button disabled={!signedIn || isMe || !iCanInvite || !!busy} onclick={invite}
title={!myGuild ? 'You are not in a guild here' : !iCanInvite ? 'Only guild leaders and officers can invite' : ''}>
{#if busy === 'invite'}<LoaderCircle class="spin" size={14} />{:else}<UserPlus size={14} />{/if}
Invite to {myGuild ? `[${myGuild.tag}]` : 'my guild'}
</button>
</div>
{/if}
</aside>
<style>
.panel { position: absolute; top: 0.8rem; right: 0.8rem; bottom: 0.8rem; width: min(20rem, calc(100% - 1.6rem)); display: flex; flex-direction: column; gap: 0.9rem; padding: 1rem; overflow-y: auto; background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.7); animation: slide 0.18s ease; }
@keyframes slide { from { transform: translateX(1.5rem); opacity: 0; } }
.close { position: absolute; top: 0.5rem; right: 0.5rem; }
.center { flex: 1; display: grid; place-items: center; }
.pad { padding: 1rem; }
.head { display: flex; gap: 0.8rem; align-items: center; padding-right: 1.6rem; }
.ring { border: 3px solid var(--pa); border-radius: 28%; overflow: hidden; flex-shrink: 0; }
.who { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.who h3 { margin: 0; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; }
.title { display: inline-flex; align-items: center; gap: 0.3rem; color: #fbbf24; font-size: 0.78rem; }
.presence { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--muted); }
.presence i { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
.presence i.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.chip { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; font-weight: 650; padding: 0.18rem 0.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 8%, transparent); }
.chip.lv { color: var(--pa); }
.chip.guild { color: #22d3ee; }
.chip.rank { color: #fbbf24; }
.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.facts div { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.4rem; align-items: center; padding: 0.5rem 0.6rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 5%, transparent); font-size: 0.72rem; color: var(--muted); }
.facts strong { grid-column: 1 / -1; color: var(--text); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bio { margin: 0; font-size: 0.82rem; color: var(--text-2, var(--text)); line-height: 1.4; }
.actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto; }
.actions button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; }
.msg { display: flex; gap: 0.4rem; }
.msg input { flex: 1; min-width: 0; }
.small { font-size: 0.75rem; margin: 0; }
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { app } from '../lib/store.svelte';
// A player's name that opens their profile. Safe to place inside other clickable rows.
let { uuid, name = '', class: cls = '', children }: { uuid?: string | null; name?: string; class?: string; children?: Snippet } = $props();
const open = (e: Event) => { e.stopPropagation(); if (uuid) app.viewProfileUuid = uuid; };
</script>
{#if uuid}
<button type="button" class="plink {cls}" title="View {name || 'player'}'s profile" onclick={open}
onkeydown={(e) => e.stopPropagation()}>{#if children}{@render children()}{:else}{name}{/if}</button>
{:else}
<span class={cls}>{#if children}{@render children()}{:else}{name}{/if}</span>
{/if}
<style>
.plink { all: unset; cursor: pointer; font: inherit; color: inherit; border-radius: 4px; transition: color 0.12s ease; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: inherit; vertical-align: bottom; }
.plink:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.plink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
@@ -30,6 +30,7 @@
let editBanner = $state('');
let editAccent = $state('');
let relationBusy = $state(false);
let confirmRemove = $state(false);
const swatches = ['#8b6cff', '#22d3ee', '#34d399', '#fbbf24', '#fb7185', '#f472b6', '#60a5fa'];
let savingProfile = $state(false);
@@ -215,7 +216,13 @@
<div class="relation-bar">
{#if profile.relationship === 'accepted'}
<button class="sm primary" onclick={message}><MessageSquare size={14} /> Message</button>
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}><UserMinus size={14} /> Remove friend</button>
{#if confirmRemove}
<span class="tiny muted">Remove {profile.username} from your friends?</span>
<button class="sm danger" disabled={relationBusy} onclick={() => { confirmRemove = false; relate('remove'); }}>Yes, remove</button>
<button class="sm ghost" onclick={() => (confirmRemove = false)}>Keep</button>
{:else}
<button class="sm ghost" disabled={relationBusy} onclick={() => (confirmRemove = true)}><UserMinus size={14} /> Remove friend</button>
{/if}
{:else if profile.relationship === 'pending_incoming'}
<span class="tiny muted">{profile.username} wants to be friends</span>
<button class="sm primary" disabled={relationBusy} onclick={() => relate('accept')}><UserCheck size={14} /> Accept</button>
@@ -275,19 +282,19 @@
<!-- Segmented Profile Tabs -->
<div class="profile-tabs">
<button class:active={activeTab === 'overview'} onclick={() => (activeTab = 'overview')}>
<LayoutGrid size={14} /> Overview
<LayoutGrid size={14} /> <span>Overview</span>
</button>
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={14} /> Posts & Feed
<MessageSquare size={14} /> <span>Posts</span>
</button>
<button class:active={activeTab === 'progression'} onclick={() => (activeTab = 'progression')}>
<Award size={14} /> Levels & Ranks
<Award size={14} /> <span>Levels</span>
</button>
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
<Trophy size={14} /> Achievements ({profile.achievements_count ?? profile.achievements?.length ?? 0})
<Trophy size={14} /> <span>Awards {profile.achievements_count ?? profile.achievements?.length ?? 0}</span>
</button>
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
<Swords size={14} /> Stats
<Swords size={14} /> <span>Stats</span>
</button>
</div>
@@ -538,7 +545,7 @@
}
.profile-modal {
width: 100%;
max-width: 36rem;
max-width: 38rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
background: var(--surface);
@@ -661,21 +668,24 @@
font-size: 0.72rem;
}
.profile-tabs {
display: flex;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 90%, black);
padding: 0 1rem;
gap: 0.3rem;
padding: 0 0.5rem;
gap: 0.15rem;
}
.profile-tabs button {
display: flex;
align-items: center;
gap: 0.35rem;
justify-content: center;
min-width: 0;
gap: 0.3rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 0.65rem 0.9rem;
font-size: 0.82rem;
padding: 0.65rem 0.25rem;
font-size: 0.8rem;
font-weight: 560;
color: var(--muted);
border-radius: 0;
@@ -685,8 +695,10 @@
color: var(--text);
border-bottom-color: var(--pa, var(--accent));
}
.profile-tabs { overflow-x: auto; }
.profile-tabs button { white-space: nowrap; }
.profile-tabs button span { overflow: hidden; text-overflow: ellipsis; }
.profile-tabs button :global(svg) { flex-shrink: 0; }
@media (max-width: 34rem) { .profile-tabs button :global(svg) { display: none; } }
.presence { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
.presence.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
.chips-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.25rem; }
+1 -1
View File
@@ -86,7 +86,7 @@
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); }
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.65rem; bottom: 0.65rem; width: 3.5px; border-radius: 0 3px 3px 0; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
.inst:hover .tip { opacity: 1; }
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
.big { width: 2.6rem; height: 2.6rem; position: relative; border-radius: var(--radius-sm); transition: transform 0.12s; }
+1 -1
View File
@@ -67,7 +67,7 @@
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
.name { gap: 0.5rem; }
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name strong { min-width: 0; flex: 0 1 auto; font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
.state.online { color: var(--success); }
-208
View File
@@ -1,208 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type * as Three from 'three';
import type { VantageViewer } from '@thoughts-on-things/vantage-mc/three';
import type { GuildClaim } from '../lib/types';
import { invoke, inTauri } from '../lib/tauri';
let { world, serverId, claims = [], dimension = 'minecraft:overworld', myGuildId = null, central = false, fill = false, onselect = () => {}, ondimension = () => {}, ondimensions = () => {}, oncenterchange = () => {}, onready = () => {}, onerror }: {
world: string;
serverId: number;
claims?: GuildClaim[];
dimension?: string;
myGuildId?: string | null;
/** Served by the panel's central live map (session-authenticated) rather than an external Vantage host. */
central?: boolean;
/** Fill the parent's height instead of using the compact territory size. */
fill?: boolean;
onselect?: (x: number, z: number) => void;
ondimension?: (dimension: 'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end') => void;
ondimensions?: (dimensions: string[]) => void;
oncenterchange?: (x: number, z: number) => void;
onready?: () => void;
onerror: (message: string) => void;
} = $props();
let host: HTMLDivElement;
let THREE: typeof import('three');
let viewer: VantageViewer | null = null;
let overlay: Three.Group | null = null;
let error = $state('');
let ready = $state(false);
function surface(x: number, z: number): number {
return (viewer?.controls.heightAt?.(x, z) ?? viewer?.controls.position.y ?? 64) + 2;
}
function label(text: string, mine: boolean): Three.Sprite {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 96;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = mine ? 'rgba(9, 65, 46, .94)' : 'rgba(70, 27, 37, .94)';
ctx.roundRect(0, 4, 512, 88, 15);
ctx.fill();
ctx.font = 'bold 35px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(text.slice(0, 28), 256, 48, 484);
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }));
sprite.scale.set(24, 4.5, 1);
sprite.renderOrder = 1001;
return sprite;
}
function clearOverlay() {
if (!overlay || !viewer) return;
viewer.scene.remove(overlay);
overlay.traverse((node) => {
if (node instanceof THREE.Line) { node.geometry.dispose(); (node.material as Three.Material).dispose(); }
if (node instanceof THREE.Sprite) {
node.material.map?.dispose();
node.material.dispose();
}
});
overlay = null;
}
function drawClaims() {
if (!viewer || !ready) return;
clearOverlay();
const group = new THREE.Group();
for (const claim of claims.slice(0, 1000)) {
const mine = claim.guild_id === myGuildId;
const x = claim.chunk_x * 16;
const z = claim.chunk_z * 16;
const points: Three.Vector3[] = [];
for (let t = 0; t <= 16; t += 4) points.push(new THREE.Vector3(x + t, surface(x + t, z), z));
for (let t = 4; t <= 16; t += 4) points.push(new THREE.Vector3(x + 16, surface(x + 16, z + t), z + t));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x + t, surface(x + t, z + 16), z + 16));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x, surface(x, z + t), z + t));
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color: mine ? 0x4ade80 : 0xfb7185, depthTest: false, linewidth: 2 }));
line.renderOrder = 1000;
group.add(line);
const name = label(claim.guild_name || claim.guild_tag || 'Guild', mine);
name.position.set(x + 8, surface(x + 8, z + 8) + 4, z + 8);
group.add(name);
}
viewer.scene.add(group);
overlay = group;
viewer.invalidate();
}
$effect(() => { claims; myGuildId; drawClaims(); });
onMount(() => {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | undefined;
let redraw: ReturnType<typeof setTimeout> | undefined;
let down: { x: number; y: number } | null = null;
let lastX = Number.NaN;
let lastZ = Number.NaN;
const downHandler = (e: PointerEvent) => { down = { x: e.clientX, y: e.clientY }; };
const upHandler = (e: PointerEvent) => {
if (!viewer || !down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return;
const rect = viewer.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2((e.clientX - rect.left) / rect.width * 2 - 1, -(e.clientY - rect.top) / rect.height * 2 + 1);
const ray = new THREE.Raycaster();
ray.setFromCamera(ndc, viewer.camera);
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -viewer.controls.position.y);
const hit = new THREE.Vector3();
if (!ray.ray.intersectPlane(plane, hit)) return;
for (let i = 0; i < 4; i++) {
plane.constant = -surface(hit.x, hit.z);
if (!ray.ray.intersectPlane(plane, hit)) break;
}
onselect(Math.floor(hit.x / 16), Math.floor(hit.z / 16));
};
Promise.all([import('three'), import('@thoughts-on-things/vantage-mc/three'), import('@thoughts-on-things/vantage-mc/core')]).then(async ([three, vantage, core]) => {
THREE = three;
const source = inTauri ? core.worldFromHttp(world, { fetch: async (url, init) => {
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const headers = new Headers(init?.headers);
const result = await invoke<{ status: number; data: string | null; etag: string | null }>(central ? 'fetch_livemap_resource' : 'fetch_vantage_resource', {
serverId, url, etag: headers.get('if-none-match')
});
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const body = result.data ? Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0)) : null;
return new Response(body, { status: result.status, headers: result.etag ? { etag: result.etag } : {} });
} }) : Promise.resolve(world);
const resolved = await source;
if (cancelled) return null;
const v = new vantage.VantageViewer(host, { view: 'top', players: { offline: false } });
try {
await v.load({ world: resolved, view: 'top' });
return v;
} catch (e) {
v.dispose();
throw e;
}
}).then((v) => {
if (!v) return;
if (cancelled) { v.dispose(); return; }
viewer = v;
ready = true;
onready();
// A central map serves one manifest per dimension, so the dimension is chosen by URL, not by the viewer.
if (!central) {
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [v.dimension?.id || 'minecraft:overworld']);
const initialId = v.dimension?.id;
if (initialId === 'minecraft:overworld' || initialId === 'minecraft:the_nether' || initialId === 'minecraft:the_end') ondimension(initialId);
}
const canvas = v.renderer.domElement;
canvas.addEventListener('pointerdown', downHandler);
canvas.addEventListener('pointerup', upHandler);
v.on('dimension', ({ current }) => {
if (central) return;
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [current?.id || 'minecraft:overworld']);
const id = current?.id;
if (id === 'minecraft:overworld' || id === 'minecraft:the_nether' || id === 'minecraft:the_end') ondimension(id);
drawClaims();
});
v.on('stats', () => {
if (redraw) clearTimeout(redraw);
redraw = setTimeout(drawClaims, 250);
});
timer = setInterval(() => {
const cx = Math.floor(v.controls.position.x / 16);
const cz = Math.floor(v.controls.position.z / 16);
if (!Number.isFinite(lastX) || Math.abs(cx - lastX) >= 8 || Math.abs(cz - lastZ) >= 8) {
lastX = cx; lastZ = cz; oncenterchange(cx, cz);
drawClaims();
}
}, 1000);
drawClaims();
}).catch((e) => { if (!cancelled) { error = e instanceof Error ? e.message : String(e); onerror(error); } });
return () => {
cancelled = true;
if (timer) clearInterval(timer);
if (redraw) clearTimeout(redraw);
viewer?.renderer.domElement.removeEventListener('pointerdown', downHandler);
viewer?.renderer.domElement.removeEventListener('pointerup', upHandler);
clearOverlay();
viewer?.dispose();
viewer = null;
};
});
$effect(() => {
if (!central && viewer && ready && viewer.dimension && viewer.dimension.id !== dimension && viewer.dimensions.some((d) => d.id === dimension)) {
void viewer.setDimension(dimension).catch((e) => { error = String(e); });
}
});
</script>
<div class="vantage-map" class:fill bind:this={host}>
{#if !ready && !error}<div class="map-message">Loading live terrain map…</div>{/if}
{#if error}<div class="map-message error">Map unavailable: {error}</div>{/if}
</div>
<style>
.vantage-map { height: min(60vh, 680px); min-height: 430px; position: relative; overflow: hidden; border-radius: 14px; background: #102026; }
.vantage-map.fill { height: 100%; min-height: 0; border-radius: 0; }
.vantage-map :global(canvas) { display: block; }
.map-message { position: absolute; z-index: 1; inset: 0; display: grid; place-items: center; padding: 24px; color: #e5e7eb; text-align: center; pointer-events: none; }
.error { background: #102026; color: #fda4af; }
</style>
+23 -4
View File
@@ -133,14 +133,31 @@ const wallet = {
};
const mockWallet = () => JSON.parse(JSON.stringify(wallet));
// `?stress` in the dev URL makes every name, title and description much longer, to check that no screen lets text spill out of its box.
const STRESS = typeof location !== 'undefined' && new URLSearchParams(location.search).has('stress');
const TEXT_KEYS = /(^|_)(name|title|username|description|motd|message|desc|label|bio|tagline|text|rank|guild)(_|$)/i;
function stress(value: unknown, key = ''): unknown {
if (Array.isArray(value)) return value.map((v) => stress(v, key));
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stress(v, k)]));
if (typeof value === 'string' && TEXT_KEYS.test(key) && /^[A-Za-z][A-Za-z '&:,.\-]{2,60}$/.test(value)) return `${value} With An Extraordinarily Long Unbroken Continuation`;
return value;
}
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
const result = await mockInvokeInner(cmd, args);
return STRESS ? stress(result) : result;
}
async function mockInvokeInner(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_livemap_info':
return { enabled: true, ready: false, message: 'The map generator isn\'t set up on the panel yet.', base: '/api/livemap/1', players: 0,
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_map_info':
return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0,
dimensions: [] };
case 'get_map_overlay':
return { players: [], claims: [], pins: [], updated: null };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
case 'get_leaderboard':
@@ -218,6 +235,8 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return 21;
case 'get_my_level':
return { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] };
case 'get_reward_summaries':
return { 'quest:q1': ['$250.00', 'Diamond x3'], 'quest:q2': ['+4 claim chunks'] };
case 'get_my_quests':
return [
{ quest: { id: 'q1', title: 'Deepslate Quarry', description: 'Mine 50 deepslate blocks in the caverns.', quest_type: 'daily', category: 'mining', xp_reward: 120, stat_type: 'mine_deepslate', target_count: 50, icon: 'pickaxe', active: true }, current_count: 35, completed: false, claimed: false, expires_at: '2026-10-01T00:00:00Z' },
+10 -2
View File
@@ -90,9 +90,17 @@ export async function init() {
theme();
pickSelected();
subscribe();
app.ready = true;
invoke('show_window').catch(() => {});
if (app.panelUrl) await refresh();
if (app.panelUrl && !app.manifest) {
try {
await refresh();
} finally {
app.ready = true;
}
} else {
app.ready = true;
if (app.panelUrl) await refresh();
}
if (boot.settings.check_updates && boot.app.repo) {
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more