Compare commits

...
11 Commits
Author SHA1 Message Date
SCOPEDD 7a1f3431ae Merge pull request #3 from scopeddlol/codex/complete-private-auth-integrations
CI / Rust (fmt, clippy, tests) (push) Failing after 2m58s
CI / Web UIs (type-check + build) (launcher) (push) Successful in 1m16s
CI / Web UIs (type-check + build) (panel/web) (push) Successful in 1m13s
CI / Launcher (Windows build check) (push) Canceled after 0s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Canceled after 10m26s
CI / Docker image builds (push) Canceled after 6m26s
Feature Update: Progression (Global & Server Levels), Quests, Achievements, Guilds Land Claiming, and Social Systems
2026-09-30 19:33:29 -04:00
scoped 6a2944a88e Rework BlueMap claiming and expand ranks guilds chat and warps 2026-09-30 18:09:18 -04:00
scoped 434ed5338a Fix Paper startup before worlds load and add 0.5.0 artifacts 2026-09-30 16:58:45 -04:00
Claude c0daa0507a Admin server page: open map link; docs pointers 2026-09-30 20:25:48 +00:00
Claude 228727a395 BlueMap docs, command guide entries, early BlueMap hook on Fabric 2026-09-30 20:24:42 +00:00
Claude 2817ad2ac5 BlueMap addon, shop points, map actions wired into Paper and Fabric; client opens shop windows 2026-09-30 20:24:02 +00:00
Claude ad7285efac Map core: claim outlines, pins, shop points, action poller, guild invite commands 2026-09-30 20:19:48 +00:00
Claude e160ad8da3 Launcher: BlueMap view with player side panel, map actions, guild invitations 2026-09-30 20:15:32 +00:00
Claude 878827625a Panel: server map address, map actions queue, guild invitations 2026-09-30 20:13:44 +00:00
Claude eba2ccfdab Launcher: scrollable command guide, profile tabs fit without scrolling, confirm before removing a friend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
2026-09-30 20:07:15 +00:00
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
125 changed files with 4228 additions and 2533 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"
-17
View File
@@ -36,20 +36,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 +44,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"]
-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:
+76
View File
@@ -0,0 +1,76 @@
# BlueMap: the 3D map
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for the 3D world map. BlueMap runs on each Minecraft server as a plugin or mod and serves the map on its own web address. SCOPENET adds to it, and the launcher shows it inside the app exactly as a browser would.
## What you get
| On the map | Where it comes from |
|---|---|
| **Guild claims** as coloured regions with the guild's icon and name (one colour per guild, outlines follow the real shape, holes included) | the claim index |
| **Spawn, warps and guild homes** as pins | the server |
| **Homes** as pins (off by default, see privacy) | the server |
| **Markets and shops** as pins that list what is for sale | shop points + the panel's market |
| **Players** with BlueMap's own live markers; **click one** for a side panel | BlueMap + SCOPENET |
The player panel shows level, guild, rank, title, playtime and server level. Inside the launcher it also offers **Send teleport request**, **Send message** and **Invite to my guild** (you need to be signed in with a SCOPENET account, and the other player must be online on that server; inviting needs a leader or officer role). In a plain browser it shows the info and points you to the launcher for the actions.
## Set it up
1. **Install BlueMap** on the server: the Paper plugin, or the Fabric/Forge mod, from <https://bluemap.bluecolored.de/>. Start the server once. BlueMap asks you to accept a download of the Minecraft client files in its config (`core.conf`: `accept-download: true`), then renders the world.
2. **Make its web address reachable.** BlueMap serves on port 8100 by default. Put it behind HTTPS with a reverse proxy (recommended; see below) or open the port.
3. **Install the SCOPENET plugin or Fabric mod** (Paper, or Fabric 1.20.1). It finds BlueMap by itself and adds its markers and script. Nothing else to configure.
4. **Tell the panel the address:** *Servers → your server → Settings → Map address (BlueMap)*, for example `https://map.example.com`. Players then see a **Live Map** button for that server in the launcher.
`bluemap.enabled` (Paper `config.yml`, Fabric `scopenet.properties`) turns the addon off. `bluemap.show-homes` / `bluemap.show_homes` shows every player's homes.
## Shop points (physical shops)
A shop point is a block that opens the market or the server shop when right-clicked. It works with the normal windows (chest window on Paper; the client mod's shop and market windows where installed) and appears as a pin on the map.
```
/market point add <name> [market|shop] (look at a block; needs scopenet.command.market.point, default op)
/market point remove <name>
/market point list
```
`market` opens the player market and lists recent listings on the map pin; `shop` opens the server shop.
## Inside the launcher
The launcher loads your map address in a frame, so everything BlueMap does in a browser works there too. SCOPENET's script on the map page passes player clicks to the launcher, which shows the side panel. A few things make it work or not:
* **Use HTTPS.** Loading an `http://` map inside an HTTPS page can be blocked as mixed content on some systems.
* **Don't forbid framing.** If your reverse proxy sends `X-Frame-Options: DENY` or a `Content-Security-Policy: frame-ancestors` that excludes the launcher, the map won't load in the app. Remove that header for the map address. The launcher's **Browser** button opens the same map in your browser either way.
* The map address is checked by the panel (web address only, no credentials).
## Privacy
* The map is public to anyone who knows its address, like any BlueMap. **Homes are off by default** for that reason. Claims and guild names are visible by design.
* `players.json`, which drives the browser panel, holds only public facts (name, level, title, guild, rank, playtime). Never balances.
* Actions from the map go through the panel, are rate-limited, and need a signed-in account. A message or teleport request reaches only players who are online on that server.
## Reverse proxy example (nginx)
```nginx
server {
server_name map.example.com;
listen 443 ssl;
location / {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection ""; # BlueMap uses server-sent events for live updates
proxy_buffering off;
}
}
```
## How it works (for developers)
* `net.scopenet.core.map` builds the map data without any map software: `ChunkRegions` turns chunks into outlines (tested with an area-conservation check), `MapModel` builds pins and regions and escapes every name, `MapService` gathers it from any thread.
* `integrations/bluemap` (`MapAddon`) is the only code that touches BlueMap's API. It copies `scopenet.js`, `scopenet.css` and the icons into BlueMap's web root, registers them, and refreshes the marker sets every 10 seconds.
* Map actions are queued in the panel (`server_actions`); the server collects them every 3 seconds (`ActionPoller`) and carries them out with its normal rules. Guild invitations are real invitations (`guild_invites`) that the other player accepts in the launcher or with `/guild accept`.
## Not verified yet
The BlueMap addon, the Paper and Fabric wiring, and the launcher's map view have not been compiled or run against a real BlueMap and Minecraft: the build environment cannot download them. The geometry, map model, shop points, action poller and guild-invite commands are unit-tested, and so are the panel endpoints. Build it, and report what BlueMap says in its log and what the map shows.
+3 -5
View File
@@ -23,11 +23,9 @@ 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.
To show a 2D world map and claim land in the launcher, install BlueMap on the
game server and configure its public web address in the panel. See
[Live map setup](live-map.md).
The named `panel-data` volume holds the database, uploads, hosted launcher
downloads, and the generated JWT signing key. Preserve that volume when
+11 -63
View File
@@ -1,73 +1,21 @@
# Live Map (central Vantage)
# Live map and land claims
> **Setting it up?** Follow the step-by-step guide: [vantage-setup.md](vantage-setup.md). This page is the overview.
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for terrain. Install BlueMap on the Minecraft server and make its web app reachable from players' computers. In the panel's **Servers → server settings**, enter the BlueMap **Map address**. The launcher opens the same map in its Live Map window and reads BlueMap's low resolution 2D tile images for the Guilds territory view.
The 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.
The terrain address must serve BlueMap's `settings.json`, per-map `settings.json`, and low resolution PNG tiles from the same URL path. A URL such as `https://maps.example.com/` works. If BlueMap is hosted below a path, include the trailing slash, for example `https://example.com/bluemap/`.
```
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
```
## Center on the player
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*.
Turn on **Live player positions** in the server settings and leave `livemap.enabled: true` in the SCOPENET plugin or mod configuration. The game server sends current positions to the panel. The panel returns each signed-in player only their own position to the launcher. The **Find me** button centers the territory view on it; the blue dot marks their position. Positions expire when the server stops sending updates.
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.
The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable.
## Turn it on
## Claim controls
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.
In **Guilds & Territories**, select the game server and dimension. Left click a chunk to claim it for your guild, or drag across chunks to claim an area. Right click or right drag unclaims your guild's chunks. Alt drag pans; the wheel zooms. The overlay shows your guild's claims in green and other guilds' claims in red. The panel checks membership and claim limits for every request; only successful claims appear in the final map state.
## Panel requirements
BlueMap must have rendered the area to show terrain. Claims can still be managed by the in-game `/claim` and `/unclaim` commands when the web map is unavailable.
Rendering needs two things on the machine running the panel:
## Existing world mirror
| 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.
Older SCOPENET game integrations uploaded region chunks to the panel under `/api/server/v1/livemap`. The panel retains those ingest endpoints for compatibility with existing servers, but the launcher terrain comes from BlueMap. Current integrations send player positions only.
+21
View File
@@ -0,0 +1,21 @@
# Ranks, guild roles, chat and warps
## Level ranks
In the panel's **Leveling** page, create a **Player Title / Prefix** reward at each global level milestone. A title can name a LuckPerms group and a Discord role ID. The highest title the player has earned is shown in Paper chat and, when LuckPerms is installed, its configured group is assigned automatically. SCOPENET only changes LuckPerms groups explicitly named on title rewards. Existing panel group to LuckPerms mappings still work independently.
In **Settings → Discord community**, enable role sync and optionally set a base role ID. Linked players receive the Discord role for their highest earned title; players without a mapped title receive the base role. When a higher title replaces a lower one, the old managed title role is removed. Players without a linked Discord account cannot receive a Discord role; in-game chat shows the base `Novice` title until they earn a title.
Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname: message`. LuckPerms prefixes and suffixes decorate the name. Messages support `**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`, backtick code, and Bukkit `&` formatting. `&#RRGGBB` colors and `&` codes require `scopenet.chat.color`, which is granted to operators by default. Players set or clear a persistent nickname with `/nick <name|off>`; chat also respects display names set by other plugins.
## Guilds
Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`.
The in-app territory view uses the configured BlueMap 2D tile address for each server. It centers on the player's last position when position relay is enabled. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
## Warps and item sharing on Paper
Players use `/reqwarp <name>` at the requested location. An operator or player with `scopenet.command.warp.manage` uses `/warprequests`, then `/warpapprove <name>` or `/warpdeny <name>`. Admins can create and remove warps directly with `/setwarp <name>` and `/delwarp <name>`. Pending requests are stored in `plugins/SCOPENET/warp_requests.yml`; approved warps are stored in `warps.yml`.
`/hand` sends the held item as a clickable chat entry. Other players can inspect a read-only snapshot for five minutes. The launcher **Command Guide** lists these commands and their permission nodes.
-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.
@@ -52,3 +52,10 @@ placeholders.enabled=true
# --- Client mod link (1.20.1) ------------------------------------------------
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
clientlink.enabled=true
# --- BlueMap (1.20.1) --------------------------------------------------------
# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a
# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address).
bluemap.enabled=true
# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link.
bluemap.show_homes=false
+13 -4
View File
@@ -64,10 +64,8 @@ essentials:
rtp-radius: 2500
livemap:
# Send world chunks and player positions to the panel's central 3D Live Map.
# Nothing runs on this server; the panel renders it. Needs "Live Map" switched on
# for this server in the panel, and Vantage installed on the panel.
# Setup guide: docs/vantage-setup.md
# Send live player positions to the panel so the launcher can center its
# BlueMap territory view. Configure BlueMap's public address in the panel.
enabled: true
economy:
@@ -114,6 +112,17 @@ integrations:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# ==============================================================================
bluemap:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -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; }
+16
View File
@@ -0,0 +1,16 @@
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
@@ -0,0 +1,211 @@
package net.scopenet.bluemap;
import com.flowpowered.math.vector.Vector2d;
import com.flowpowered.math.vector.Vector3d;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.BlueMapAPI;
import de.bluecolored.bluemap.api.BlueMapMap;
import de.bluecolored.bluemap.api.markers.Marker;
import de.bluecolored.bluemap.api.markers.MarkerSet;
import de.bluecolored.bluemap.api.markers.POIMarker;
import de.bluecolored.bluemap.api.markers.ShapeMarker;
import de.bluecolored.bluemap.api.math.Color;
import de.bluecolored.bluemap.api.math.Shape;
import net.scopenet.core.map.ChunkRegions;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapModel;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
/**
* Puts SCOPENET on the BlueMap 3D map:
* <ul>
* <li>guild land as coloured regions, with the guild's icon and name,</li>
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
* <li>markets and shops as pins that list what is for sale,</li>
* <li>a script that opens a side panel when you click a player.</li>
* </ul>
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
* nothing if BlueMap isn't installed.
*/
public final class MapAddon {
/** Where the addon gets its data. Both calls must be safe from any thread. */
public interface Feed {
MapData snapshot();
JsonObject playersJson();
}
static final String WEB_DIR = "assets/scopenet";
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
static final float CLAIM_Y = 66f;
private final Feed feed;
private final Logger log;
private ScheduledExecutorService worker;
private volatile boolean enabled;
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
/** Hook into BlueMap. Returns a handle to stop it. */
public static MapAddon start(Feed feed, Logger log) {
MapAddon addon = new MapAddon(feed, log);
BlueMapAPI.onEnable(addon::enable);
BlueMapAPI.onDisable(api -> addon.disable());
return addon;
}
public void stop() { disable(); }
// ---- lifecycle -------------------------------------------------------------------------
private synchronized void enable(BlueMapAPI api) {
try {
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
Files.createDirectories(root);
for (String name : ASSETS) copy(name, root.resolve(name));
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
} catch (IOException | RuntimeException e) {
log.warning("SCOPENET could not add its files to BlueMap: " + e);
return;
}
enabled = true;
worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-bluemap");
t.setDaemon(true);
return t;
});
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
}
private synchronized void disable() {
enabled = false;
if (worker != null) worker.shutdownNow();
worker = null;
}
private static void copy(String name, Path target) throws IOException {
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
if (in == null) throw new IOException("missing resource " + name);
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// ---- refresh ---------------------------------------------------------------------------
private void refresh(BlueMapAPI api) {
if (!enabled) return;
try {
MapData data = feed.snapshot();
writePlayers(api);
for (BlueMapMap map : api.getMaps()) update(map, data);
} catch (RuntimeException | IOException e) {
log.fine("SCOPENET map refresh failed: " + e);
}
}
private void writePlayers(BlueMapAPI api) throws IOException {
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
Path tmp = file.resolveSibling("players.json.tmp");
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
private void update(BlueMapMap map, MapData data) {
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
}
/** Replace a marker set's contents in place, so the map doesn't flicker. */
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
wanted.forEach(set::put);
}
// ---- markers ---------------------------------------------------------------------------
private static Shape shape(ChunkRegions.Ring ring) {
List<Vector2d> points = new ArrayList<>();
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
return new Shape(points);
}
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
ShapeMarker marker = ShapeMarker.builder()
.label(c.name())
.shape(shape(c.outer()), CLAIM_Y)
.holes(holes)
.lineColor(new Color(c.color(), 0.95f))
.fillColor(new Color(c.color(), 0.28f))
.lineWidth(3)
.depthTestEnabled(false)
.detail(c.detail())
.build();
out.put("claim-" + c.id(), marker);
}
return out;
}
/** One icon and name per area, in the middle of it, fading out when you are far away. */
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
POIMarker.Builder builder = POIMarker.builder()
.label(c.name() + " [" + c.tag() + "]")
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
.styleClasses("scopenet-guild-icon")
.maxDistance(1600)
.detail(c.detail());
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
out.put("guild-" + c.id(), builder.build());
}
return out;
}
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.Pin p : data.pins()) {
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
if (!p.dimension().equals(dimension) || isShop != shops) continue;
String icon = switch (p.kind()) {
case "spawn" -> "spawn.svg";
case "warp" -> "warp.svg";
case "home" -> "home.svg";
case "guild_home" -> "guild-home.svg";
case "market" -> "market.svg";
default -> "shop.svg";
};
out.put(p.id(), POIMarker.builder()
.label(p.label())
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
.icon(WEB_DIR + "/" + icon, 16, 16)
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
.maxDistance(2400)
.detail(p.detail())
.build());
}
return out;
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 243 B

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

After

Width:  |  Height:  |  Size: 288 B

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

After

Width:  |  Height:  |  Size: 229 B

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

After

Width:  |  Height:  |  Size: 308 B

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

After

Width:  |  Height:  |  Size: 387 B

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

After

Width:  |  Height:  |  Size: 270 B

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

After

Width:  |  Height:  |  Size: 290 B

+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,14 +8,20 @@ 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<>();
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"));
economyCommands = economy;
guildCommands = guilds;
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
@@ -27,6 +33,25 @@ 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(); }
@@ -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;
@@ -132,6 +132,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))); }
@@ -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) {
@@ -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,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 BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
public static String dimensionOf(String id, String name) {
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
if (words.contains("nether")) return "minecraft:the_nether";
if (words.contains("end")) return "minecraft:the_end";
return "minecraft:overworld";
}
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
public static String iconAddress(String icon, String panelBase) {
if (icon == null || icon.isBlank()) return "";
String i = icon.trim();
if (i.startsWith("https://") || i.startsWith("http://")) return i;
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
return "";
}
public static MapData build(Sources s) {
List<MapData.ClaimRegion> claims = new ArrayList<>();
s.claims().forEach((dimension, guilds) -> {
for (ClaimIndex.GuildClaims g : guilds) {
int n = 0;
for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) {
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()),
guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail));
}
}
});
List<MapData.Pin> pins = new ArrayList<>();
int i = 0;
for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(),
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(),
home.pos().x(), home.pos().y(), home.pos().z(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
if (s.showHomes()) {
s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> {
String who = s.homeOwners().getOrDefault(owner, "a player");
pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
}));
}
String marketDetail = marketSummary(s.market());
for (ShopPoints.Shop shop : s.shops()) {
boolean market = shop.kind().equals("market");
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
Pos p = shop.pos();
pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail));
}
pins.sort(Comparator.comparing(MapData.Pin::id));
return new MapData(claims, pins, s.players());
}
/** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */
static String marketSummary(JsonArray listings) {
if (listings == null || listings.size() == 0) return "<br><em>No listings right now.</em>";
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
int shown = 0;
for (JsonElement e : listings) {
if (shown++ >= 5) break;
JsonObject l = e.getAsJsonObject();
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name");
b.append("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
.append(" &middot; ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0))
.append(" <span>by ").append(escape(guild)).append("</span></li>");
}
return b.append("</ul>").toString();
}
private static String text(JsonObject o, String key) { return o.has(key) && o.get(key).isJsonPrimitive() ? o.get(key).getAsString() : ""; }
/** The file the map page reads to describe players who are online (public facts only: never balances). */
public static JsonObject playersJson(String server, List<MapData.OnlinePlayer> players, long now) {
JsonObject root = new JsonObject();
root.addProperty("updated", now);
root.addProperty("server", server);
JsonObject byId = new JsonObject();
for (MapData.OnlinePlayer p : players) {
JsonObject o = new JsonObject();
o.addProperty("name", p.name());
o.addProperty("level", p.level());
o.addProperty("title", p.title());
o.addProperty("serverLevel", p.serverLevel());
o.addProperty("guild", p.guild());
o.addProperty("guildTag", p.guildTag());
o.addProperty("guildRole", p.guildRole());
o.addProperty("rank", p.rank());
o.addProperty("playtime", p.playtimeSecs());
byId.add(p.uuid(), o);
}
root.add("players", byId);
return root;
}
}
@@ -0,0 +1,78 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.core.*;
import net.scopenet.integration.ClaimIndex;
import java.util.*;
/**
* Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
*/
public final class MapService {
public static final long MARKET_REFRESH_MS = 60_000;
private final Env env;
private final ClaimIndex claims;
private final MapSource places;
private final PlayerCache cache;
private final String panelBase;
private final boolean showHomes;
private final String serverName;
private volatile JsonArray market = new JsonArray();
private volatile long marketAt = Long.MIN_VALUE / 2;
private volatile boolean fetching;
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
}
public String serverName() { return serverName; }
/** Server thread, about once a second. */
public void tick() {
long now = env.clock.getAsLong();
if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return;
fetching = true;
marketAt = now;
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
fetching = false;
}, e -> fetching = false);
}
/** Safe from any thread: reads only copies and concurrent collections. */
public MapData snapshot() {
List<MapData.OnlinePlayer> players = onlinePlayers();
Map<UUID, String> names = new HashMap<>();
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
Map<String, String> labels = new HashMap<>();
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
MapModel.Sources sources = new MapModel.Sources(
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
env.features.essentials() ? places.warps() : Map.of(),
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
market, players, panelBase, showHomes, names);
return MapModel.build(sources);
}
private List<MapData.OnlinePlayer> onlinePlayers() {
List<MapData.OnlinePlayer> out = new ArrayList<>();
for (CorePlayer p : env.platform.online()) {
JsonObject i = cache.info(p.uuid());
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank");
out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""),
(long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""),
PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0)));
}
return out;
}
public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); }
}
@@ -0,0 +1,17 @@
package net.scopenet.core.map;
import net.scopenet.core.Pos;
import net.scopenet.core.ShopPoints;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
public interface MapSource {
Map<String, Pos> warps();
Map<UUID, Map<String, Pos>> homes();
/** Guild homes by guild id. */
Map<String, Pos> guildHomes();
Collection<ShopPoints.Shop> shops();
}
@@ -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");
@@ -144,13 +144,10 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler;
}
/**
* Start mirroring this server's world and player positions to the panel's
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
*/
public void enableLiveMap(java.nio.file.Path worldRoot) {
/** Start sending player positions for the in-app BlueMap territory view. */
public void enableLiveMap() {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client, worldRoot, log);
liveMap = new LiveMapSync(client);
liveMap.start();
}
@@ -160,12 +157,6 @@ public final class Integration implements AutoCloseable {
if (map != null) map.offerPlayers(players);
}
/** The world was just saved: pick up its changed chunks soon. */
public void liveMapSaved() {
LiveMapSync map = liveMap;
if (map != null) map.requestScan();
}
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
}
@@ -1,363 +1,70 @@
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;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 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}.
*/
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private 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;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "scopenet-positions");
thread.setDaemon(true);
return thread;
});
/** 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;
private volatile boolean changed;
private volatile boolean closed;
private long lastSent;
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
this.client = client;
this.root = root;
this.log = log;
}
public LiveMapSync(PanelClient client) { this.client = client; }
public void start() {
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
}
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
playersChanged = true;
changed = true;
}
}
/** A save just happened; scan soon instead of waiting for the next interval. */
public void requestScan() {
if (closed || !scanQueued.compareAndSet(false, true)) return;
private void sendSafely() {
if (closed) return;
long now = System.nanoTime();
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject entry = new JsonObject();
entry.addProperty("uuid", p.uuid());
entry.addProperty("name", p.name());
entry.addProperty("dimension", p.dimension());
entry.addProperty("x", p.x());
entry.addProperty("y", p.y());
entry.addProperty("z", p.z());
entry.addProperty("yaw", p.yaw());
entry.addProperty("pitch", p.pitch());
list.add(entry);
}
JsonObject body = new JsonObject();
body.add("players", list);
try {
worker.schedule(() -> {
scanQueued.set(false);
nextScan = 0;
scanSafely();
}, 3, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
scanQueued.set(false);
client.post("livemap/players", body);
changed = false;
lastSent = now;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// Keep the latest snapshot and retry on the next interval.
}
}
@Override public void close() {
closed = true;
WebSocket ws = socket;
if (ws != null) ws.abort();
worker.shutdownNow();
}
// ---- chunks ----
private void scanSafely() {
if (closed || System.nanoTime() < nextScan) return;
try {
if (!refreshEnabled()) return;
long uploaded = scan();
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
}
}
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
private boolean refreshEnabled() throws IOException, InterruptedException {
if (panelEnabled) return true;
long now = System.nanoTime();
if (now < configCheckAt) return false;
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
try {
JsonObject config = client.get("livemap/config");
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
} catch (PanelClient.HttpFailure e) {
panelEnabled = false; // an older panel without live map support
}
return panelEnabled;
}
/** Returns how many bytes of chunk data were uploaded. */
private long scan() throws IOException, InterruptedException {
if (!Files.isDirectory(root)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET live map: world folder " + root + " not found");
}
return 0;
}
uploadLevelIfChanged();
long total = 0;
for (String dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
loadManifest(dim);
Batch batch = new Batch(dim);
List<Path> files = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
stream.forEach(files::add);
}
files.sort(Comparator.comparing(Path::toString));
for (Path file : files) {
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
total += readRegion(dim, file, batch);
}
total += batch.flush();
if (total >= SCAN_BUDGET_BYTES) break;
}
return total;
}
private Path regionDir(String dim) {
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
Path parent = root.toAbsolutePath().getParent();
List<Path> candidates = switch (dim) {
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
root.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
root.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
private void loadManifest(String dim) throws IOException, InterruptedException {
if (manifestLoaded.contains(dim)) return;
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
for (JsonElement el : manifest.getAsJsonArray("regions")) {
JsonObject region = el.getAsJsonObject();
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
int[] ts = new int[1024];
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
}
manifestLoaded.add(dim);
}
private static String key(String dim, int rx, int rz) {
return dim + ":" + rx + ":" + rz;
}
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
return 0;
}
long flushed = 0;
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long size = ch.size();
if (size < 2L * SECTOR) return 0;
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
for (int i = 0; i < 1024; i++) {
int loc = header.getInt(i * 4);
int ts = header.getInt(SECTOR + i * 4);
int count = loc & 0xff;
long offset = (loc >>> 8) & 0xffffffL;
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
if (offset < 2 || (offset + count) * SECTOR > size) continue;
ByteBuffer prefix = ByteBuffer.allocate(5);
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
if (prefix.hasRemaining()) continue;
int length = prefix.getInt(0);
int compression = prefix.get(4) & 0xff;
// Oversized chunks live in external .mcc files; skip those and anything malformed.
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
ByteBuffer data = ByteBuffer.allocate(length - 1);
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
if (data.hasRemaining()) continue;
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
}
}
return flushed;
}
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
private final class Batch {
final String dim;
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<int[]> marks = new ArrayList<>();
int size;
Batch(String dim) {
this.dim = dim;
}
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
out.write(rec.array(), 0, rec.capacity());
marks.add(new int[]{rx, rz, index, ts});
size += rec.capacity();
}
long flush() throws IOException, InterruptedException {
if (marks.isEmpty()) return 0;
long sent = size;
try {
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
} finally {
out.reset();
marks.clear();
size = 0;
}
return sent;
}
}
private void uploadLevelIfChanged() throws IOException, InterruptedException {
Path level = root.resolve("level.dat");
if (!Files.isRegularFile(level)) return;
long modified = Files.getLastModifiedTime(level).toMillis();
if (modified == levelModified) return;
byte[] bytes = Files.readAllBytes(level);
if (bytes.length > 2 * 1024 * 1024) return;
client.postBytes("livemap/level", bytes);
levelModified = modified;
}
// ---- players ----
private void sendPlayersSafely() {
if (closed || !panelEnabled) return;
long now = System.nanoTime();
// Resend unchanged rosters now and then so the panel knows we're alive.
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
if (!playersChanged && !heartbeat) return;
playersChanged = false;
lastPlayersSent = now;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.addProperty("yaw", p.yaw());
o.addProperty("pitch", p.pitch());
list.add(o);
}
JsonObject body = new JsonObject();
body.addProperty("type", "players");
body.add("players", list);
try {
WebSocket ws = socket;
if (ws != null && !ws.isOutputClosed()) {
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
return;
}
connectSocket();
client.post("livemap/players", body);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
socket = null; // reconnect later; HTTPS covers the gap
}
}
private void connectSocket() {
long now = System.nanoTime();
if (now < socketRetryAt) return;
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
try {
Settings settings = client.settings();
URI panel = settings.panel();
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
client.http().newWebSocketBuilder()
.header("Authorization", "Bearer " + settings.token())
.connectTimeout(Duration.ofSeconds(5))
.buildAsync(uri, new WebSocket.Listener() {
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
ws.request(1);
return null;
}
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
if (socket == ws) socket = null;
return null;
}
@Override public void onError(WebSocket ws, Throwable error) {
if (socket == ws) socket = null;
}
}).thenAccept(ws -> {
if (closed) ws.abort();
else socket = ws;
});
} catch (RuntimeException ignored) {
// The HTTPS fallback keeps working.
}
}
}
@@ -0,0 +1,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());
}
}
@@ -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());
+2
View File
@@ -16,6 +16,7 @@ dependencies {
implementation project(':common')
if (features) {
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
implementation project(':bluemap')
// Optional at runtime: present only when the LuckPerms mod is installed.
compileOnly 'net.luckperms:api:5.4'
}
@@ -31,5 +32,6 @@ processResources {
jar {
archiveBaseName = "scopenet-fabric-${mc}"
from project(':common').sourceSets.main.output
if (features) from project(':bluemap').sourceSets.main.output
}
base.archivesName = "scopenet-fabric-${mc}"
@@ -7,5 +7,5 @@
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" }
}
@@ -0,0 +1,28 @@
package net.scopenet.fabric.features;
import net.scopenet.bluemap.MapAddon;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapService;
import java.util.logging.Logger;
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
final class BlueMapHook {
private BlueMapHook() {}
/**
* Registered while the mod initialises (BlueMap reads its script list when it starts), so the data comes from
* {@code service}, which only exists once the server is running: until then the map is simply empty.
*/
static Object start(java.util.function.Supplier<MapService> service, Logger log) {
return MapAddon.start(new MapAddon.Feed() {
@Override public MapData snapshot() { MapService s = service.get(); return s == null ? MapData.empty() : s.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() {
MapService s = service.get();
return s == null ? net.scopenet.core.map.MapModel.playersJson("", java.util.List.of(), System.currentTimeMillis()) : s.playersJson();
}
}, log);
}
static void stop(Object handle) { ((MapAddon) handle).stop(); }
}
@@ -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,12 +54,19 @@ public final class FabricFeatures implements Runnable {
private ClientLink link;
private Perms perms;
private FabricConfig config;
private ActionPoller poller;
private MapService map;
private Object blueMap;
private int ticks;
private String placeholderStatus = "off";
CommandSet commandSet() { return commands; }
@Override public void run() {
// Hook into BlueMap early: it reads the list of web scripts when it starts.
if (FabricConfig.load().bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) {
try { blueMap = BlueMapHook.start(() -> map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); }
}
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
@@ -64,6 +76,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 +116,10 @@ public final class FabricFeatures implements Runnable {
integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
// Actions from the map, and the BlueMap addon (claims, pins, player panel) when BlueMap is installed.
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
config.bool("bluemap.show_homes", false), s.getMotd());
registerCommands(s.getCommands().getDispatcher());
registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
@@ -103,8 +130,10 @@ public final class FabricFeatures implements Runnable {
private void stopping() {
ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false;
if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; }
if (platform != null) platform.shutdown();
commands = null;
map = null;
}
private void registerPlaceholders() {
@@ -125,6 +154,8 @@ public final class FabricFeatures implements Runnable {
if (ticks % 20 == 0) {
cache.tick(p -> link.pushState(p));
link.tick();
poller.tick();
map.tick();
}
if (ticks % 40 == 0) commands.tick();
}
@@ -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.liveMapEnabled(config)) integration.enableLiveMap();
} 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);
+3 -1
View File
@@ -6,6 +6,7 @@ repositories {
dependencies {
implementation project(':common')
implementation project(':api')
implementation project(':bluemap')
implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
// Optional integrations: the plugin detects these at runtime and works without them.
@@ -22,6 +23,7 @@ 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 project(':bluemap').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
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 MapService mapService;
private Object blueMap;
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,7 +63,70 @@ final class ClientLinkBridge implements PluginMessageListener {
}, 40, 20);
}
/**
* Shop points, actions from the map, and the BlueMap addon. 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("bluemap.show-homes", false), Bukkit.getServer().getName());
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20);
if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) {
blueMap = BlueMapHook.start(mapService, plugin.getLogger());
}
}
/** /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);
}
}
}
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
private static final class BlueMapHook {
static Object start(MapService service, Logger log) {
return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() {
@Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
}, log);
}
static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); }
}
void stop() {
if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; }
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
}
@@ -78,7 +154,13 @@ 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);
}
@@ -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,16 @@ 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.
// Live positions center the in-app BlueMap claim view on the player.
if (getConfig().getBoolean("livemap.enabled", true)) {
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath());
integration.enableLiveMap();
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
}
// 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 +82,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 +106,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 (when BlueMap is installed) claims, pins and the player panel on it.
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 +128,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);
}
}
@@ -159,15 +176,15 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
integration.liveMapPlayers(list);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void worldSaved(org.bukkit.event.world.WorldSaveEvent event) {
if (integration != null) integration.liveMapSaved();
}
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 +362,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) {
@@ -160,6 +169,12 @@ 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; }
}
@@ -172,6 +187,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) {
@@ -277,6 +314,11 @@ 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());
@@ -362,6 +404,88 @@ 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;
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) {
@@ -52,9 +52,8 @@ essentials:
rtp-radius: 2500
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.
# Send live player positions to the panel for the launcher BlueMap claim view.
# Configure BlueMap's public address and enable live positions in the panel.
enabled: true
economy:
@@ -94,6 +93,17 @@ integrations:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# ==============================================================================
bluemap:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -4,7 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap]
commands:
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
+1 -1
View File
@@ -6,7 +6,7 @@ pluginManagement {
}
}
rootProject.name = 'scopenet-integrations'
include 'common', 'api', 'paper'
include 'common', 'api', 'paper', 'bluemap'
// Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
+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",
+132 -30
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]
@@ -630,7 +640,7 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
.filter_map(|s| Some((s["id"].as_i64()?, s["map_address"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
@@ -643,15 +653,14 @@ pub struct MapResource {
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.
/// Native transport for BlueMap settings and 2D tiles. Only paths under the
/// BlueMap address supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&configured).map_err(err)?;
let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.join(".").map_err(err)?;
let root = base.clone();
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
@@ -659,7 +668,7 @@ pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64,
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured Vantage world".into());
return Err("map asset is outside the configured BlueMap site".into());
}
fetch_map_asset(target, etag, None).await
}
@@ -676,26 +685,6 @@ pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res
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.
#[tauri::command]
pub async fn fetch_livemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
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());
}
fetch_map_asset(target, etag, Some(token)).await
}
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 {
@@ -723,11 +712,11 @@ async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Opt
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());
return Err("BlueMap 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());
return Err("BlueMap asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
}
@@ -807,6 +796,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 +1244,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?;
+14 -2
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,8 +97,7 @@ pub fn run() {
commands::get_player_stats,
commands::get_leaderboard,
commands::get_public_servers,
commands::fetch_vantage_resource,
commands::fetch_livemap_resource,
commands::fetch_bluemap_resource,
commands::get_livemap_info,
commands::get_my_level,
commands::get_my_quests,
@@ -101,6 +105,14 @@ pub fn run() {
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,
+9 -3
View File
@@ -10,9 +10,15 @@ 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,7 +41,7 @@ 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.
/// BlueMap addresses received from the panel, keyed by linked game server.
pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
+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": {
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
import { openUrl } from '../lib/tauri';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
let frame = $state<HTMLIFrameElement>();
let loaded = $state(false);
let selected = $state<{ uuid: string; name: string } | null>(null);
let nonce = $state(0);
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
function onMessage(e: MessageEvent) {
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
const m = e.data;
if (!m || m.source !== 'scopenet-map') return;
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
else if (m.type === 'deselect') selected = null;
}
onMount(() => {
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
});
const reload = () => { loaded = false; selected = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={loaded}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<iframe
bind:this={frame}
title="{serverName} live map"
src={address}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
allow="fullscreen; clipboard-write"
referrerpolicy="no-referrer"
onload={() => (loaded = true)}
></iframe>
{/key}
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
{#if selected}
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
{/if}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
</style>
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke, inTauri } from '../lib/tauri';
import type { GuildClaim } from '../lib/types';
type Resource = { status: number; data: string | null };
type Site = { maps?: string[]; mapDataRoot?: string };
type MapSettings = { lowres?: { tileSize?: [number, number]; lodFactor?: number; lodCount?: number } };
type Point = { x: number; z: number };
const cache = new Map<string, string>();
const pending = new Set<string>();
let { address, serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
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();
let host: HTMLDivElement;
let width = $state(800), height = $state(480);
let pixelsPerBlock = $state(3);
let worldX = $state(8), worldZ = $state(8);
let mapRoot = $state('');
let mapId = $state('');
let tileBlocks = $state(32);
let images = $state<Record<string, string>>({});
let 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);
async function resource(url: string): Promise<Resource> {
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
const response = await fetch(url);
if (!response.ok) return { status: response.status, data: null };
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return { status: response.status, data: btoa(binary) };
}
async function json<T>(url: string): Promise<T> {
const response = await resource(url);
if (response.status !== 200 || !response.data) throw new Error(`BlueMap returned ${response.status} for settings`);
return JSON.parse(atob(response.data)) as T;
}
function tilePath(x: number, z: number): string {
const part = (n: number) => (n < 0 ? '-' : '') + Math.abs(n).toString().split('').join('/');
return `x${part(x)}/z${part(z)}.png`;
}
function chooseMap(ids: string[]): string {
const slug = dimension.replace('minecraft:', '');
if (slug === 'overworld') return ids.find((id) => !/nether|the_end|(^|[_-])end($|[_-])/i.test(id)) ?? '';
return ids.find((id) => slug === 'the_nether' ? /nether/i.test(id) : /(^|[_-])end($|[_-])/i.test(id)) ?? '';
}
$effect(() => {
const url = address, dim = dimension;
let alive = true;
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
(async () => {
const base = new URL(url.endsWith('/') ? url : `${url}/`);
const site = await json<Site>(new URL('settings.json', base).href);
const id = chooseMap(site.maps ?? []);
if (!id) throw new Error(`BlueMap has no ${dim.replace('minecraft:', '')} map`);
const root = new URL(`${(site.mapDataRoot || 'maps').replace(/\/$/, '')}/${encodeURIComponent(id)}/`, base).href;
const settings = await json<MapSettings>(new URL('settings.json', root).href);
if (!alive) return;
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
mapRoot = root; mapId = id; loading = false;
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
return () => { alive = false; };
});
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
const left = $derived(worldX - width / (2 * pixelsPerBlock));
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
const visibleTiles = $derived.by(() => {
if (!mapRoot) return [] as { key: string; url: string; left: number; top: number; size: number }[];
const out = [];
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
const url = new URL(`tiles/1/${tilePath(x, z)}`, mapRoot).href;
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
}
return out;
});
$effect(() => {
const tiles = visibleTiles;
for (const tile of tiles) {
if (images[tile.key] || pending.has(tile.key)) continue;
if (cache.has(tile.key)) { images = { ...images, [tile.key]: cache.get(tile.key)! }; continue; }
pending.add(tile.key);
resource(tile.url).then((r) => {
if (r.status !== 200 || !r.data) return;
const src = `data:image/png;base64,${r.data}`;
cache.set(tile.key, src);
if (cache.size > 512) cache.delete(cache.keys().next().value!);
images = { ...images, [tile.key]: src };
}).catch(() => {}).finally(() => pending.delete(tile.key));
}
});
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
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 (!mapRoot) 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="BlueMap territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
{#each visibleTiles as tile (tile.key)}
{#if images[tile.key]}<img class="tile" src={images[tile.key]} alt="" draggable="false" style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />{/if}
{/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 BlueMap tiles…</div>{/if}
{#if error}<div class="message error">{error}</div>{/if}
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
<div class="coords">{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}</div>
</div>
<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; }
.error { color: #fda4af; }
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
.help { left: 10px; }.coords { right: 10px; }
</style>
+120 -475
View File
@@ -1,524 +1,169 @@
<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 { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
import BlueMapTiles from './BlueMapTiles.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_address?: string; live_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<{ me?: Player | null }>('get_livemap_info', { serverId: id });
if (serverId !== id) return;
player = info.me ?? 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_address}
{#key `${serverId}:${selectedServer.map_address}`}
<BlueMapTiles address={selectedServer.map_address} 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">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</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">Player position appears when the server sends live positions to the panel.</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>
@@ -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>
@@ -0,0 +1,164 @@
<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 { 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>{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>
@@ -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; }
-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>
+2 -2
View File
@@ -137,9 +137,9 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
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 }] };
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_livemap_info':
return { enabled: true, ready: false, message: 'The map generator isn\'t set up on the panel yet.', base: '/api/livemap/1', players: 0,
return { enabled: true, ready: true, message: '', me: null, players: 0,
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
+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(() => {});
}
+2 -1
View File
@@ -222,6 +222,7 @@ export interface Guild {
name: string;
tag: string;
description: string;
motd?: string;
icon_url: string | null;
banner_url: string | null;
leader_uuid: string;
@@ -234,7 +235,7 @@ export interface Guild {
export interface GuildMember {
uuid: string;
name: string;
role: 'leader' | 'officer' | 'member';
role: string;
joined_at: string;
online?: boolean;
}
+12 -1
View File
@@ -12,6 +12,10 @@
{ usage: '/delhome <name>', does: 'Delete one of your homes.', perm: 'scopenet.command.delhome', aliases: ['rmhome'] },
{ usage: '/back', does: 'Return to where you were, or where you died.', perm: 'scopenet.command.back', aliases: ['return'] },
{ usage: '/warp [name]', does: 'Go to a server warp, or open the warps menu.', perm: 'scopenet.command.warp', aliases: ['warps'] },
{ usage: '/reqwarp <name>', does: 'Request a public warp at your current location.', perm: 'scopenet.command.reqwarp' },
{ usage: '/warprequests', does: 'Staff: list pending warp requests.', perm: 'scopenet.command.warp.manage' },
{ usage: '/warpapprove <name> · /warpdeny <name>', does: 'Staff: approve or deny a warp request.', perm: 'scopenet.command.warp.manage' },
{ usage: '/setwarp <name> · /delwarp <name>', does: 'Staff: create or delete public warps.', perm: 'scopenet.command.warp.manage' },
{ usage: '/rtp', does: 'Jump to a random safe spot in the wild.', perm: 'scopenet.command.rtp', aliases: ['wild', 'randomtp'] },
{ usage: '/tpa <player>', does: 'Ask to teleport to another player.', perm: 'scopenet.command.tpa' },
{ usage: '/tpaccept', does: 'Accept a teleport request.', perm: 'scopenet.command.tpaccept', aliases: ['tpyes'] },
@@ -26,6 +30,7 @@
{ usage: '/sell', does: 'Open the selling menu.', perm: 'scopenet.command.sell' },
{ usage: '/market', does: 'Browse and buy from the player market.', perm: 'scopenet.command.market', aliases: ['ah', 'auction'] },
{ usage: '/market sell <price>', does: 'List the item in your hand on the market.', perm: 'scopenet.command.market.sell' },
{ usage: '/market point add <name> [market|shop]', does: 'Staff: turn the block you look at into a shop that opens the market or shop when right-clicked. Also shown on the map.', perm: 'scopenet.command.market.point' },
{ usage: '/orders', does: 'View your active market orders.', perm: 'scopenet.command.orders' },
{ usage: '/trade <player>', does: 'Start a secure trade with another player.', perm: 'scopenet.command.trade' },
{ usage: '/transactions', does: 'See your recent money movements.', perm: 'scopenet.command.transactions' },
@@ -38,8 +43,11 @@
{ usage: '/guild chat <message>', does: 'Talk to your guild only.', perm: 'scopenet.command.guild.chat', aliases: ['/guild c'] },
{ usage: '/guild sethome · /guild home', does: 'Set or visit the guild home.', perm: 'scopenet.command.guild.sethome' },
{ usage: '/guild map', does: 'Show nearby claims on a map.', perm: 'scopenet.command.guild.map' },
{ usage: '/guild invite <player>', does: 'Invite a player to your guild (leaders and officers).', perm: 'scopenet.command.guild.invite' },
{ usage: '/guild accept [tag] · /guild decline [tag]', does: 'Answer a guild invitation. You can also answer in the launcher.', perm: 'scopenet.command.guild.accept' },
{ usage: '/claim', does: 'Claim the chunk you stand in for your guild.', perm: 'scopenet.command.claim' },
{ usage: '/unclaim', does: 'Give up the chunk you stand in.', perm: 'scopenet.command.unclaim' },
{ usage: '/autoclaim on|off', does: 'Automatically claim new chunks as you walk.', perm: 'scopenet.command.claim' },
] },
{ title: 'Guild bank', blurb: 'Shared guild money. Officers and leaders can spend it.', cmds: [
{ usage: '/guild bank', does: 'Show the bank balance and recent activity.', perm: 'scopenet.command.guild.bank' },
@@ -51,6 +59,8 @@
] },
{ title: 'Info & admin', blurb: 'Status and help.', cmds: [
{ usage: '/playtime', does: 'Check your total and session playtime.', perm: 'scopenet.command.playtime', aliases: ['ontime'] },
{ usage: '/hand', does: 'Share your held item as an inspectable chat link.', perm: 'scopenet.command.hand' },
{ usage: '/nick <name|off>', does: 'Set or remove your chat nickname.', perm: 'scopenet.command.nick' },
{ usage: '/scopenet help', does: 'List SCOPENET commands.', perm: 'scopenet.command.scopenet.help', aliases: ['sn'] },
{ usage: '/scopenet status', does: 'Panel connection, features and plugin integrations (admins).', perm: 'scopenet.command.scopenet.status' },
{ usage: '/scopenet reload', does: 'Reload the plugin config (admins).', perm: 'scopenet.command.scopenet.reload' },
@@ -59,6 +69,7 @@
const tips = [
'Placeholders: use %scopenet_level%, %scopenet_guild%, %scopenet_balance% and more in scoreboards, tab and chat.',
'Right-click a shop point (a marked block) to open the market or shop. On a server with the SCOPENET client mod it opens the shop window.',
'Some commands may be switched off or limited by the server. If one says you lack permission, ask an admin.',
'Everything here also works through your rank: servers using LuckPerms can grant each permission node to any group.',
];
@@ -117,7 +128,7 @@
</div>
<style>
.page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 1.6rem 2.2rem 2.5rem; gap: 1.2rem; }
.page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; min-height: 0; padding: 1.6rem 2.2rem 2.5rem; gap: 1.2rem; }
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 1rem; }
h1 { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 0.6rem; margin: 0; }
:global(.ic) { color: var(--accent); }
+144 -2
View File
@@ -17,7 +17,16 @@
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet' | 'settings'>('territory');
type GuildRole = { id: number; name: string; can_invite: boolean; can_kick: boolean; can_claim: boolean; can_post: boolean; can_manage: boolean };
type JoinRequest = { uuid: string; name: string; message: string; created_at: string };
let roles = $state<GuildRole[]>([]);
let joinRequests = $state<JoinRequest[]>([]);
let newRoleName = $state('');
let roleFlags = $state({ can_invite: false, can_kick: false, can_claim: false, can_post: true, can_manage: false });
let guildEdit = $state({ description: '', motd: '', icon_url: '', banner_url: '' });
const myRole = $derived(members.find((m) => m.uuid === activeAccount()?.uuid)?.role ?? '');
const canManageDetails = $derived(myRole === 'leader' || myRole === 'officer' || !!roles.find((r) => r.name === myRole)?.can_manage);
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
@@ -58,9 +67,14 @@
]);
members = mList;
posts = pList;
roles = await invoke<GuildRole[]>('get_guild_roles', { guildId: mine.id }).catch(() => []);
joinRequests = await invoke<JoinRequest[]>('get_guild_join_requests', { guildId: mine.id }).catch(() => []);
guildEdit = { description: mine.description ?? '', motd: mine.motd ?? '', icon_url: mine.icon_url ?? '', banner_url: mine.banner_url ?? '' };
} else {
members = [];
posts = [];
roles = [];
joinRequests = [];
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load guild data', 'error');
@@ -69,7 +83,77 @@
}
}
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string };
let invites = $state<Invite[]>([]);
async function loadInvites() {
try { invites = await invoke<Invite[]>('my_guild_invites'); } catch { invites = []; }
}
async function answerInvite(inv: Invite, accept: boolean) {
try {
await invoke('respond_guild_invite', { inviteId: inv.id, accept });
toast(accept ? `Welcome to ${inv.guild_name}!` : 'Invitation declined', 'ok');
await Promise.all([loadInvites(), loadInstanceGuildData()]);
} catch (e: any) {
toast(typeof e === 'string' ? e : e?.message ?? 'Could not answer the invitation', 'error');
loadInvites();
}
}
async function requestJoin(guild: Guild) {
try {
await invoke('request_guild_join', { guildId: guild.id, message: '' });
toast(`Request sent to ${guild.name}`, 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function answerJoin(req: JoinRequest, accept: boolean) {
if (!myGuild) return;
try {
await invoke('respond_guild_join_request', { guildId: myGuild.id, uuid: req.uuid, accept });
toast(accept ? `${req.name} joined the guild` : 'Request declined', 'ok');
await loadInstanceGuildData();
} catch (e: any) { toast(String(e), 'error'); }
}
async function createRole() {
if (!myGuild || !newRoleName.trim()) return;
try {
await invoke('create_guild_role', { guildId: myGuild.id, role: { name: newRoleName.trim(), ...roleFlags } });
newRoleName = '';
roles = await invoke<GuildRole[]>('get_guild_roles', { guildId: myGuild.id });
toast('Guild role created', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function assignRole(member: GuildMember, role: string) {
if (!myGuild) return;
try {
await invoke('assign_guild_role', { guildId: myGuild.id, uuid: member.uuid, role });
member.role = role as GuildMember['role'];
toast('Guild role assigned', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function deleteRole(role: GuildRole) {
if (!myGuild) return;
try {
await invoke('delete_guild_role', { guildId: myGuild.id, roleId: role.id });
await loadInstanceGuildData();
toast('Guild role deleted', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function saveGuild() {
if (!myGuild) return;
try {
await invoke('update_guild', { guildId: myGuild.id, description: guildEdit.description, motd: guildEdit.motd, iconUrl: guildEdit.icon_url, bannerUrl: guildEdit.banner_url });
await loadInstanceGuildData();
toast('Guild details saved', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
onMount(async () => {
loadInvites();
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; }
});
@@ -169,6 +253,20 @@
</div>
</div>
{#if invites.length}
<section class="invites glass" aria-label="Guild invitations">
<h3>Guild invitations</h3>
{#each invites as inv (inv.id)}
<div class="invite">
<span class="dot"><Shield size={15} /></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by {inv.inviter}</span></span>
<button class="sm ghost" onclick={() => answerInvite(inv, false)}>Decline</button>
<button class="sm primary" onclick={() => answerInvite(inv, true)} disabled={!!myGuild} title={myGuild ? 'Leave your guild first' : ''}>Accept</button>
</div>
{/each}
</section>
{/if}
{#if loading && !myGuild && !allGuilds.length}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else if myGuild}
@@ -211,6 +309,7 @@
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
<Landmark size={15} /> Treasury
</button>
{#if canManageDetails}<button class:active={activeTab === 'settings'} onclick={() => (activeTab = 'settings')}>Guild settings</button>{/if}
</div>
</div>
@@ -239,6 +338,13 @@
<GuildWallet guild={myGuild} {members} servers={walletServers} instanceId={selectedInstId} />
{:else if activeTab === 'members'}
<div class="members-tab">
{#if joinRequests.length}
<div class="card"><h3>Join requests</h3>
{#each joinRequests as req}
<div class="row"><strong>{req.name}</strong><span>{req.message}</span><span class="spacer"></span><button class="sm" onclick={() => answerJoin(req, false)}>Decline</button><button class="sm primary" onclick={() => answerJoin(req, true)}>Accept</button></div>
{/each}
</div>
{/if}
<div class="members-grid">
{#each members as m (m.uuid)}
<div
@@ -261,16 +367,42 @@
<span class="badge leader-badge"><Crown size={11} /> Leader</span>
{:else if m.role === 'officer'}
<span class="badge officer-badge"><UserCheck size={11} /> Officer</span>
{:else}
{:else if m.role === 'member'}
<span class="badge member-role-badge">Member</span>
{:else}
<span class="badge member-role-badge">{m.role}</span>
{/if}
</div>
<span class="tiny muted">Joined {new Date(m.joined_at).toLocaleDateString()}</span>
{#if myGuild.leader_uuid === activeAccount()?.uuid && m.uuid !== activeAccount()?.uuid}
<select value={m.role} aria-label="Role for {m.name}" onchange={(e) => assignRole(m, e.currentTarget.value)}>
<option value="member">Member</option><option value="officer">Officer</option>
{#each roles as role}<option value={role.name}>{role.name}</option>{/each}
</select>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if activeTab === 'settings'}
<div class="card col">
<h3>Guild appearance</h3>
<label>Description<textarea bind:value={guildEdit.description} maxlength="500"></textarea></label>
<label>Message of the day<input bind:value={guildEdit.motd} maxlength="200" /></label>
<label>Icon URL<input bind:value={guildEdit.icon_url} type="url" /></label>
<label>Banner URL<input bind:value={guildEdit.banner_url} type="url" /></label>
<button class="primary" onclick={saveGuild}>Save guild details</button>
</div>
{#if myRole === 'leader'}<div class="card col">
<h3>Guild roles</h3>
{#each roles as role}
<div class="row"><strong>{role.name}</strong><span class="tiny muted">{[role.can_invite && 'Invite', role.can_kick && 'Kick', role.can_claim && 'Claim', role.can_post && 'Post', role.can_manage && 'Manage'].filter(Boolean).join(', ') || 'No extra permissions'}</span><span class="spacer"></span><button class="sm danger" onclick={() => deleteRole(role)}>Delete</button></div>
{/each}
<label>New role name<input bind:value={newRoleName} maxlength="24" /></label>
<div class="row"><label><input type="checkbox" bind:checked={roleFlags.can_invite} /> Invite</label><label><input type="checkbox" bind:checked={roleFlags.can_kick} /> Kick</label><label><input type="checkbox" bind:checked={roleFlags.can_claim} /> Claim</label><label><input type="checkbox" bind:checked={roleFlags.can_post} /> Post</label><label><input type="checkbox" bind:checked={roleFlags.can_manage} /> Manage</label></div>
<button class="primary" onclick={createRole}>Create role</button>
</div>{/if}
{:else if activeTab === 'feed'}
<div class="feed-tab">
<!-- Post Announcement Form -->
@@ -376,6 +508,7 @@
{#if g.description}
<p class="dir-desc">{g.description}</p>
{/if}
<button class="sm primary" onclick={() => requestJoin(g)}>Request to join</button>
</div>
{/each}
</div>
@@ -405,6 +538,10 @@
<span class="field-label">Banner Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/banner.png" bind:value={newGuildBanner} />
</label>
<label>
<span class="field-label">Icon Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/guild-icon.png" bind:value={newGuildIcon} />
</label>
</div>
{#snippet footer()}
<button class="ghost" onclick={() => (createModalOpen = false)}>Cancel</button>
@@ -416,6 +553,11 @@
</Modal>
<style>
.invites { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.9rem 1.1rem; border-radius: var(--radius); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); }
.invites h3 { margin: 0; font-size: 0.95rem; }
.invite { display: flex; align-items: center; gap: 0.7rem; }
.invite .dot { width: 2rem; height: 2rem; display: grid; place-items: center; border-radius: 0.6rem; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
.invite .text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.guilds-page {
height: 100%;
display: flex;
+7 -7
View File
@@ -4,7 +4,7 @@
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import LiveMapModal from '../components/LiveMapModal.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
import { invoke, openUrl } from '../lib/tauri';
@@ -13,8 +13,8 @@
const b = $derived(app.manifest!.branding);
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
// A game server linked to this instance with the panel's Live Map switched on.
let mapServer = $state<{ id: number; name: string } | null>(null);
// A game server linked to this instance with BlueMap configured.
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
let mapOpen = $state(false);
$effect(() => {
const id = inst?.id;
@@ -22,8 +22,8 @@
mapOpen = false;
if (!id) return;
let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.live_map) ?? null; })
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
.catch(() => {});
return () => { alive = false; };
});
@@ -57,7 +57,7 @@
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live 3D map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
@@ -82,7 +82,7 @@
</aside>
{#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer}<LiveMapModal serverId={mapServer.id} serverName={mapServer.name} onclose={() => (mapOpen = false)} />{/if}
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
</div>
<style>
-1
View File
@@ -750,7 +750,6 @@
<button class="sm link-btn" disabled={checking} onclick={checkUpdate}>{#if checking}<LoaderCircle class="spin" size={14} />{:else}<RefreshCw size={14} />{/if} Check for updates</button>
{/if}
<p class="tiny muted">Built with Tauri, Rust and Svelte. Minecraft is a trademark of Mojang Studios; this launcher is not affiliated with Mojang or Microsoft.</p>
{#if app.boot?.app.repo}<button class="sm link-btn" onclick={() => openUrl(`https://github.com/${app.boot?.app.repo}`)}>Source code</button>{/if}
</div>
{/if}
</div>
+14 -1
View File
@@ -34,6 +34,9 @@
// Send Game Invite Modal
let inviteModalOpen = $state(false);
let removeTarget = $state<FriendInfo | null>(null);
let removeModalOpen = $state(false);
const askRemove = (friend: FriendInfo) => { removeTarget = friend; removeModalOpen = true; };
let inviteTargetFriend = $state<FriendInfo | null>(null);
let inviteInstanceId = $state(instances()[0]?.id ?? '');
let sendingInvite = $state(false);
@@ -381,7 +384,7 @@
<button class="ghost icon sm" onclick={() => openInviteModal(friend)} title="Invite to Game">
<Gamepad2 size={15} />
</button>
<button class="ghost icon sm danger-hover" onclick={() => handleRemoveFriend(friend.uuid)} title="Remove Friend">
<button class="ghost icon sm danger-hover" onclick={() => askRemove(friend)} title="Remove Friend">
<UserX size={15} />
</button>
</div>
@@ -625,6 +628,16 @@
</div>
<!-- Send Game Invite Modal -->
<Modal bind:open={removeModalOpen} title="Remove {removeTarget?.username}?" width={24}>
<p class="muted">{removeTarget?.username} will be removed from your friends. You can send a new request later.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (removeModalOpen = false)}>Keep friend</button>
<button class="danger" onclick={() => { const f = removeTarget; removeModalOpen = false; if (f) handleRemoveFriend(f.uuid); }}>
<UserX size={14} /> Remove
</button>
{/snippet}
</Modal>
<Modal bind:open={inviteModalOpen} title="Invite {inviteTargetFriend?.username} to Play" width={24}>
<div class="invite-modal-body">
<p class="tiny muted">
-9
View File
@@ -14,12 +14,6 @@ pub struct Config {
pub max_upload_mb: usize,
pub public_url: Option<String>,
pub trusted_proxies: Vec<std::net::IpAddr>,
/// Vantage generator binary (defaults to `vantage` on PATH or /opt/vantage).
pub vantage_bin: Option<PathBuf>,
/// Minecraft client assets directory handed to Vantage.
pub vantage_assets: Option<PathBuf>,
/// Extra arguments appended to `vantage server`.
pub vantage_args: Vec<String>,
}
fn var(name: &str) -> Option<String> {
@@ -41,9 +35,6 @@ impl Config {
trusted_proxies: var("SCOPENET_TRUSTED_PROXIES")
.map(|s| s.split(',').map(|v| v.trim().parse().expect("SCOPENET_TRUSTED_PROXIES must contain IP addresses")).collect())
.unwrap_or_default(),
vantage_bin: var("SCOPENET_VANTAGE_BIN").map(Into::into),
vantage_assets: var("SCOPENET_VANTAGE_ASSETS").map(Into::into),
vantage_args: var("SCOPENET_VANTAGE_ARGS").map(|s| s.split_whitespace().map(str::to_owned).collect()).unwrap_or_default(),
}
}
+39
View File
@@ -680,6 +680,45 @@ const MIGRATIONS: &[&str] = &[
r#"
ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT '';
"#,
// BlueMap: a map address per server, in-game actions triggered from the map, and guild invitations.
r#"
ALTER TABLE game_servers ADD COLUMN map_address TEXT NOT NULL DEFAULT '';
CREATE TABLE server_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
from_uuid TEXT NOT NULL DEFAULT '',
from_name TEXT NOT NULL DEFAULT '',
to_uuid TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
taken_at TEXT
);
CREATE INDEX server_actions_pending ON server_actions(server_id, taken_at);
CREATE INDEX server_actions_sender ON server_actions(from_uuid, created_at);
CREATE TABLE guild_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
inviter_uuid TEXT NOT NULL,
inviter_name TEXT NOT NULL,
target_uuid TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL
);
CREATE INDEX guild_invites_target ON guild_invites(target_uuid, status);
"#,
// Player initiated guild applications.
r#"
CREATE TABLE guild_join_requests (
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
name TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (guild_id, uuid)
);
CREATE INDEX guild_join_requests_player ON guild_join_requests(uuid);
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+1 -1
View File
@@ -63,7 +63,7 @@ pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, yg
keys: Arc::new(auth::Keys::new(&secret)),
http: scopenet_core::http::client(),
login_guard: Arc::new(auth::LoginGuard::default()),
livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir, cfg.vantage_bin.clone(), cfg.vantage_assets.clone(), cfg.vantage_args.clone())),
livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir)),
cfg: Arc::new(cfg),
})
}
+5 -277
View File
@@ -1,20 +1,10 @@
//! Central live map.
//!
//! Game servers (the SCOPENET plugin/mod) push the chunks that changed and
//! their players' positions to the panel. The panel keeps a mirror of each
//! server's Anvil region files under `<data>/livemap/<server>/world`, runs a
//! Vantage `server` sidecar over that mirror on demand, and proxies the
//! sidecar to authenticated launcher/admin sessions. Nobody has to run
//! Vantage next to their Minecraft server.
//! Live player positions and the legacy world mirror received from game servers.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
pub const SECTOR: usize = 4096;
const HEADER: usize = 2 * SECTOR;
@@ -244,17 +234,6 @@ struct Snapshot {
}
// ---------------------------------------------------------------------------
// Sidecar supervisor
// ---------------------------------------------------------------------------
struct Sidecar {
child: tokio::process::Child,
port: u16,
token: String,
last_used: Instant,
log: std::sync::Arc<Mutex<VecDeque<String>>>,
}
#[derive(Default, Clone, Serialize)]
pub struct ServerStats {
pub chunks_received: u64,
@@ -264,50 +243,19 @@ pub struct ServerStats {
pub struct LiveMap {
root: PathBuf,
bin: Option<PathBuf>,
assets: Option<PathBuf>,
extra_args: Vec<String>,
players: Mutex<HashMap<i64, Snapshot>>,
stats: Mutex<HashMap<i64, ServerStats>>,
sidecars: tokio::sync::Mutex<HashMap<(i64, Dim), Sidecar>>,
last_error: Mutex<HashMap<(i64, Dim), String>>,
write_lock: tokio::sync::Mutex<()>,
players_written: Mutex<HashMap<i64, Instant>>,
}
fn find_bin(configured: Option<&Path>) -> Option<PathBuf> {
let candidates: Vec<PathBuf> = match configured {
Some(p) => vec![p.to_path_buf()],
None => {
let mut v: Vec<PathBuf> =
std::env::var_os("PATH").map(|p| std::env::split_paths(&p).map(|d| d.join("vantage")).collect()).unwrap_or_default();
v.push("/opt/vantage/vantage".into());
v
}
};
candidates.into_iter().find(|p| p.is_file())
}
impl LiveMap {
pub fn new(data_dir: &Path, bin: Option<PathBuf>, assets: Option<PathBuf>, extra_args: Vec<String>) -> Self {
let assets = assets.or_else(|| {
let p = data_dir.join("livemap").join("assets");
p.is_dir().then_some(p)
});
pub fn new(data_dir: &Path) -> Self {
Self {
root: data_dir.join("livemap"),
bin: find_bin(bin.as_deref()),
assets,
extra_args,
players: Mutex::default(),
stats: Mutex::default(),
sidecars: Default::default(),
last_error: Mutex::default(),
write_lock: Default::default(),
players_written: Mutex::default(),
}
}
pub fn server_dir(&self, id: i64) -> PathBuf {
self.root.join(id.to_string())
}
@@ -322,23 +270,6 @@ impl LiveMap {
w.join(dim.folder()).join("region")
}
}
fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf {
self.server_dir(id).join("cache").join(dim.slug())
}
fn players_file(&self, id: i64) -> PathBuf {
self.server_dir(id).join("players.json")
}
pub fn generator_ready(&self) -> (bool, bool) {
(self.bin.is_some(), self.assets.as_ref().is_some_and(|a| a.is_dir()))
}
pub fn generator_path(&self) -> Option<String> {
self.bin.as_ref().map(|p| p.display().to_string())
}
pub fn assets_path(&self) -> Option<String> {
self.assets.as_ref().map(|p| p.display().to_string())
}
// ---- ingest ----
/// Write a batch of chunks into the server's mirror. Returns how many
@@ -394,20 +325,9 @@ impl LiveMap {
}
pub fn set_players(&self, id: i64, players: Vec<LivePlayer>) {
self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players: players.clone() });
self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players });
self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(crate::db::now());
// Vantage reads this file to decide what to pre-render; throttle it.
let mut written = self.players_written.lock().unwrap();
if written.get(&id).is_none_or(|t| t.elapsed() > Duration::from_secs(5)) {
written.insert(id, Instant::now());
let doc = players_doc(&players, None);
let path = self.players_file(id);
if std::fs::create_dir_all(self.server_dir(id)).is_ok() {
let _ = std::fs::write(path, doc.to_string());
}
}
}
/// Drop a player from every server's live roster (account deleted).
pub fn forget_player(&self, uuid: &str) {
for snap in self.players.lock().unwrap().values_mut() {
@@ -419,11 +339,6 @@ impl LiveMap {
self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default()
}
/// `players.json` for one dimension (BlueMap-compatible, which Vantage reads).
pub fn players_json(&self, id: i64, dim: Dim) -> serde_json::Value {
players_doc(&self.live_players(id), Some(dim))
}
pub fn stats(&self, id: i64) -> ServerStats {
self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default()
}
@@ -446,157 +361,8 @@ impl LiveMap {
serde_json::json!({ "level_dat": self.world_dir(id).join("level.dat").is_file(), "dimensions": dims })
}
pub async fn sidecars_running(&self, id: i64) -> Vec<String> {
self.sidecars.lock().await.keys().filter(|(s, _)| *s == id).map(|(_, d)| d.slug().to_string()).collect()
}
pub fn last_error(&self, id: i64) -> Option<String> {
let errs = self.last_error.lock().unwrap();
Dim::ALL.iter().find_map(|d| errs.get(&(id, *d)).cloned())
}
// ---- sidecars ----
/// Make sure a Vantage sidecar is serving this server/dimension and
/// return its loopback port and bearer token.
pub async fn ensure_sidecar(&self, id: i64, dim: Dim) -> Result<(u16, String), String> {
let bin = self.bin.clone().ok_or("The Vantage generator isn't installed on this panel. See docs/live-map.md.")?;
let assets = self
.assets
.clone()
.filter(|a| a.is_dir())
.ok_or("Minecraft client assets for Vantage aren't configured. See docs/live-map.md.")?;
let world = self.world_dir(id);
if !world.join("level.dat").is_file() || !self.region_dir(id, dim).is_dir() {
return Err("Waiting for the game server to upload this world.".into());
}
let mut sidecars = self.sidecars.lock().await;
if let Some(s) = sidecars.get_mut(&(id, dim)) {
if matches!(s.child.try_wait(), Ok(None)) {
s.last_used = Instant::now();
return Ok((s.port, s.token.clone()));
}
sidecars.remove(&(id, dim));
}
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
l.local_addr().map_err(|e| e.to_string())?.port()
};
let token = {
use rand::RngCore;
let mut b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut b);
hex::encode(b)
};
let cache = self.cache_dir(id, dim);
tokio::fs::create_dir_all(&cache).await.map_err(|e| e.to_string())?;
let players = self.players_file(id);
if !players.exists() {
let _ = std::fs::write(&players, players_doc(&[], None).to_string());
}
let mut cmd = tokio::process::Command::new(&bin);
cmd.arg("server")
.arg(&world)
.arg("--assets")
.arg(&assets)
.arg("--out")
.arg(&cache)
.args(["--host", "127.0.0.1", "--port"])
.arg(port.to_string())
.arg("--players-file")
.arg(&players)
.args(["--dimension", dim.slug()])
.args(&self.extra_args)
.env("VANTAGE_SERVER_TOKEN", &token)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| format!("couldn't start the Vantage generator: {e}"))?;
let log: std::sync::Arc<Mutex<VecDeque<String>>> = Default::default();
if let Some(out) = child.stdout.take() {
tail(out, log.clone());
}
if let Some(err) = child.stderr.take() {
tail(err, log.clone());
}
let mut sidecar = Sidecar { child, port, token: token.clone(), last_used: Instant::now(), log };
// Wait for it to answer health checks.
let client = reqwest::Client::new();
let url = format!("http://127.0.0.1:{port}/v1/health");
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(Some(status)) = sidecar.child.try_wait() {
let tail = sidecar
.log
.lock()
.unwrap()
.iter()
.rev()
.take(4)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join(" | ");
let msg = format!("Vantage exited ({status}) {tail}");
self.last_error.lock().unwrap().insert((id, dim), msg.clone());
return Err(msg);
}
if client.get(&url).timeout(Duration::from_secs(2)).send().await.is_ok_and(|r| r.status().is_success()) {
break;
}
if Instant::now() > deadline {
let _ = sidecar.child.start_kill();
let msg = "Vantage didn't become ready within 30 seconds".to_string();
self.last_error.lock().unwrap().insert((id, dim), msg.clone());
return Err(msg);
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
self.last_error.lock().unwrap().remove(&(id, dim));
sidecars.insert((id, dim), sidecar);
Ok((port, token))
}
/// Stop sidecars nobody has looked at recently.
pub async fn reap_idle(&self) {
let mut sidecars = self.sidecars.lock().await;
let idle: Vec<_> = sidecars.iter().filter(|(_, s)| s.last_used.elapsed() > IDLE_STOP).map(|(k, _)| *k).collect();
for key in idle {
if let Some(mut s) = sidecars.remove(&key) {
let _ = s.child.start_kill();
tracing::info!("stopped idle live-map generator for server {} ({})", key.0, key.1.slug());
}
}
}
pub fn spawn_reaper(self: &std::sync::Arc<Self>) {
let me = std::sync::Arc::downgrade(self);
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let Some(lm) = me.upgrade() else { break };
lm.reap_idle().await;
}
});
}
/// Stop every sidecar of a server and delete its mirror and caches.
/// Clear the legacy world mirror and current player positions.
pub async fn reset(&self, id: i64) -> std::io::Result<()> {
{
let mut sidecars = self.sidecars.lock().await;
let keys: Vec<_> = sidecars.keys().filter(|(s, _)| *s == id).copied().collect();
for k in keys {
if let Some(mut s) = sidecars.remove(&k) {
let _ = s.child.kill().await;
}
}
}
let _guard = self.write_lock.lock().await;
self.players.lock().unwrap().remove(&id);
self.stats.lock().unwrap().remove(&id);
@@ -606,44 +372,6 @@ impl LiveMap {
}
}
}
fn tail<R: tokio::io::AsyncRead + Unpin + Send + 'static>(r: R, log: std::sync::Arc<Mutex<VecDeque<String>>>) {
tokio::spawn(async move {
let mut lines = BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut l = log.lock().unwrap();
if l.len() >= 30 {
l.pop_front();
}
l.push_back(line.chars().take(300).collect());
}
});
}
fn players_doc(players: &[LivePlayer], dim: Option<Dim>) -> serde_json::Value {
let list: Vec<_> = players
.iter()
.map(|p| {
let here = dim.is_none_or(|d| Dim::parse(&p.dimension).unwrap_or(Dim::Overworld) == d);
serde_json::json!({
"uuid": p.uuid,
"name": p.name,
"foreign": !here,
"position": { "x": p.x, "y": p.y, "z": p.z },
"rotation": { "pitch": p.pitch, "yaw": p.yaw, "roll": 0 },
})
})
.collect();
serde_json::json!({ "players": list })
}
/// Sidecar port and token for the proxy, without starting anything.
impl LiveMap {
pub async fn sidecar_log(&self, id: i64, dim: Dim) -> Vec<String> {
self.sidecars.lock().await.get(&(id, dim)).map(|s| s.log.lock().unwrap().iter().cloned().collect()).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -22,7 +22,6 @@ async fn main() -> anyhow::Result<()> {
let bind = cfg.bind.clone();
let state = build_state(cfg, pool).await?;
bootstrap_admin(&state).await?;
state.livemap.spawn_reaper();
scopenet_panel::routes::discord::spawn_worker(state.clone());
let listener = tokio::net::TcpListener::bind(&bind).await?;
+2
View File
@@ -72,6 +72,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?);
report.add("messages", run(c, "DELETE FROM direct_messages WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
report.add("invites", run(c, "DELETE FROM game_invites WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
report.add("invites", run(c, "DELETE FROM guild_invites WHERE inviter_uuid = ? OR target_uuid = ?", &[uuid, uuid]).await?);
report.add("actions", run(c, "DELETE FROM server_actions WHERE from_uuid = ? OR to_uuid = ?", &[uuid, uuid]).await?);
report.add("profile", run(c, "DELETE FROM user_profiles WHERE uuid = ?", &[uuid]).await?);
// Likes they gave come off other people's post counters.
run(
+2 -2
View File
@@ -65,7 +65,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
}
drop(conn); // give the connection back before the pool is used again
let rank: Option<(String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
let rank: Option<(String, String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
Ok(Json(json!({
@@ -87,7 +87,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
"friends": friends,
"achievements": achievements,
"quests": quests,
"rank": rank.map(|(primary, display, prefix)| json!({ "group": primary, "display": display, "prefix": prefix })),
"rank": rank.map(|(primary, display, prefix, suffix)| json!({ "group": primary, "display": display, "prefix": prefix, "suffix": suffix })),
})))
}
+36 -7
View File
@@ -2,7 +2,8 @@
//! sync, an invite link for launchers, and optional webhook announcements.
//!
//! Everything here is opt-in and needs the bot token and server ID saved in
//! Settings. Sync only ever *adds* membership, in the direction the admin picks.
//! Settings. Group mappings only add membership in the chosen direction;
//! managed level-title roles are replaced when a player's rank changes.
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
@@ -22,6 +23,8 @@ const API: &str = "https://discord.com/api/v10";
pub struct DiscordSettings {
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
pub role_sync: String,
/// Discord role used until the first mapped rank title is earned.
pub base_role_id: String,
/// Shown to players in the launcher.
pub invite_url: String,
/// Announcements go here. Empty turns them off.
@@ -33,7 +36,7 @@ pub struct DiscordSettings {
impl Default for DiscordSettings {
fn default() -> Self {
Self { role_sync: "off".into(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
}
}
@@ -54,7 +57,7 @@ pub fn valid_invite(url: &str) -> bool {
fn view(s: &DiscordSettings) -> Value {
json!({
"role_sync": s.role_sync, "invite_url": s.invite_url,
"role_sync": s.role_sync, "base_role_id": s.base_role_id, "invite_url": s.invite_url,
"webhook_set": !s.webhook_url.is_empty(),
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members,
})
@@ -72,6 +75,8 @@ pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppRes
#[derive(Deserialize)]
pub struct SettingsInput {
role_sync: String,
#[serde(default)]
base_role_id: String,
invite_url: String,
/// Omitted or empty keeps the saved webhook; `"-"` clears it.
#[serde(default)]
@@ -85,6 +90,9 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
return Err(AppError::bad_request("unknown role sync mode"));
}
if !i.base_role_id.is_empty() && (!i.base_role_id.bytes().all(|b| b.is_ascii_digit()) || i.base_role_id.len() > 24) {
return Err(AppError::bad_request("Base Discord role ID must contain digits only"));
}
let old = load(&state).await?;
let webhook = match i.webhook_url.trim() {
"" => old.webhook_url.clone(),
@@ -100,6 +108,7 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
}
let next = DiscordSettings {
role_sync: i.role_sync,
base_role_id: i.base_role_id,
invite_url: invite,
webhook_url: webhook,
notify_achievements: i.notify_achievements,
@@ -197,6 +206,7 @@ pub struct SyncReport {
pub not_in_server: u32,
pub groups_added: u32,
pub roles_added: u32,
pub roles_removed: u32,
pub failed: u32,
}
@@ -212,11 +222,13 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
}
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
if mapping.is_empty() {
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
.fetch_all(&state.db).await?;
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
return Ok(report);
}
let linked: Vec<(i64, String)> = sqlx::query_as("SELECT user_id, provider_id FROM account_connections WHERE provider = 'discord'").fetch_all(&state.db).await?;
for (user_id, discord_id) in linked {
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
for (user_id, discord_id, uuid) in linked {
report.checked += 1;
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
let resp = match resp {
@@ -235,6 +247,7 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
report.failed += 1;
break;
}
if !resp.status().is_success() { report.failed += 1; continue; }
let Ok(member) = resp.json::<Value>().await else {
report.failed += 1;
continue;
@@ -242,11 +255,17 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
let level = crate::progression::level_from_xp(xp).0;
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
for g in p.add_groups {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
report.groups_added += 1;
}
for role in p.add_roles {
// A level title takes precedence over mapped panel groups on Discord.
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
for role in wanted_roles {
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
.header("Content-Length", "0")
.send()
@@ -257,6 +276,16 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
if let Some(chosen) = chosen {
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
managed.sort(); managed.dedup();
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
tokio::time::sleep(Duration::from_millis(120)).await;
}
Ok(report)
+143 -28
View File
@@ -437,13 +437,7 @@ pub async fn update_guild(
State(state): State<AppState>,
Json(payload): Json<UpdateGuildPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
let is_officer_or_leader = guild_can(&state, &id, &auth.uuid, "manage").await?;
if !is_officer_or_leader {
return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
}
@@ -472,6 +466,132 @@ pub struct AddMemberPayload {
pub username: String,
}
#[derive(Deserialize)]
pub struct JoinRequestPayload { #[serde(default)] pub message: String }
pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(payload): Json<JoinRequestPayload>) -> AppResult<Json<Value>> {
let instance: Option<String> = sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
let instance = instance.ok_or_else(|| AppError::not_found("guild not found"))?;
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)")
.bind(&auth.uuid).bind(&instance).fetch_one(&state.db).await?;
if in_guild { return Err(AppError::bad_request("Leave your current guild before requesting to join another")); }
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?").bind(auth.id).fetch_one(&state.db).await?;
sqlx::query("INSERT INTO guild_join_requests(guild_id,uuid,name,message,created_at) VALUES(?,?,?,?,?) ON CONFLICT(guild_id,uuid) DO UPDATE SET message=excluded.message,created_at=excluded.created_at")
.bind(&id).bind(&auth.uuid).bind(&name).bind(payload.message.trim().chars().take(300).collect::<String>()).bind(crate::db::now()).execute(&state.db).await?;
Ok(Json(json!({"ok":true})))
}
pub async fn list_join_requests(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
let rows: Vec<(String,String,String,String)> = sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
.bind(&id).fetch_all(&state.db).await?;
Ok(Json(json!(rows.into_iter().map(|(uuid,name,message,created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at})).collect::<Vec<_>>())))
}
#[derive(Deserialize)]
pub struct JoinDecision { pub accept: bool }
pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(decision): Json<JoinDecision>) -> AppResult<Json<Value>> {
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
let mut tx = state.db.begin().await?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_join_requests WHERE guild_id=? AND uuid=?")
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
if decision.accept {
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
} else {
sqlx::query("DELETE FROM guild_join_requests WHERE guild_id=? AND uuid=?").bind(&id).bind(&uuid).execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(Json(json!({"ok":true})))
}
#[derive(Deserialize)]
pub struct GuildRoleInput {
pub name: String,
#[serde(default)] pub priority: i64,
#[serde(default)] pub can_invite: bool,
#[serde(default)] pub can_kick: bool,
#[serde(default)] pub can_claim: bool,
#[serde(default)] pub can_post: bool,
#[serde(default)] pub can_manage: bool,
}
async fn guild_can(state: &AppState, guild_id: &str, uuid: &str, action: &str) -> AppResult<bool> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
let Some(role) = role else { return Ok(false); };
if role == "leader" || role == "officer" { return Ok(true); }
if role == "member" { return Ok(matches!(action, "claim" | "post")); }
let column = match action { "invite" => "can_invite", "kick" => "can_kick", "claim" => "can_claim", "post" => "can_post", "manage" => "can_manage", _ => return Ok(false) };
let allowed: Option<i64> = sqlx::query_scalar(&format!("SELECT {column} FROM guild_roles WHERE guild_id=? AND name=?"))
.bind(guild_id).bind(&role).fetch_optional(&state.db).await?;
Ok(allowed.unwrap_or(0) != 0)
}
async fn require_guild_leader(state: &AppState, guild_id: &str, uuid: &str) -> AppResult<()> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
if role.as_deref() != Some("leader") { return Err(AppError::forbidden("Only the guild leader can manage roles")); }
Ok(())
}
pub async fn list_guild_roles(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id=? AND uuid=?)")
.bind(&id).bind(&auth.uuid).fetch_one(&state.db).await?;
if !member { return Err(AppError::forbidden("Guild membership is required")); }
let roles: Vec<(i64,String,i64,i64,i64,i64,i64,i64)> = sqlx::query_as("SELECT id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage FROM guild_roles WHERE guild_id=? ORDER BY priority DESC,name")
.bind(&id).fetch_all(&state.db).await?;
Ok(Json(json!(roles.into_iter().map(|(id,name,priority,invite,kick,claim,post,manage)| json!({"id":id,"name":name,"priority":priority,"can_invite":invite!=0,"can_kick":kick!=0,"can_claim":claim!=0,"can_post":post!=0,"can_manage":manage!=0})).collect::<Vec<_>>())))
}
pub async fn create_guild_role(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(role): Json<GuildRoleInput>) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
let name = role.name.trim();
if name.len() < 2 || name.len() > 24 || !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|| ["leader","officer","member"].iter().any(|r| r.eq_ignore_ascii_case(name)) {
return Err(AppError::bad_request("Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role"));
}
let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=? COLLATE NOCASE)")
.bind(&id).bind(name).fetch_one(&state.db).await?;
if duplicate { return Err(AppError::conflict("A role with this name already exists")); }
let inserted = sqlx::query("INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)")
.bind(&id).bind(name).bind(role.priority.clamp(0,100)).bind(role.can_invite).bind(role.can_kick).bind(role.can_claim).bind(role.can_post).bind(role.can_manage)
.execute(&state.db).await?;
Ok(Json(json!({"ok":true,"id":inserted.last_insert_rowid()})))
}
#[derive(Deserialize)]
pub struct AssignRole { pub role: String }
pub async fn assign_guild_role(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(input): Json<AssignRole>) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
if input.role == "leader" { return Err(AppError::bad_request("Use leadership transfer to assign the leader role")); }
if input.role != "member" && input.role != "officer" {
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=?)")
.bind(&id).bind(&input.role).fetch_one(&state.db).await?;
if !exists { return Err(AppError::bad_request("Role does not belong to this guild")); }
}
let done = sqlx::query("UPDATE guild_members SET role=? WHERE guild_id=? AND uuid=? AND role<>'leader'")
.bind(&input.role).bind(&id).bind(&uuid).execute(&state.db).await?;
if done.rows_affected() == 0 { return Err(AppError::not_found("member not found or is guild leader")); }
Ok(Json(json!({"ok":true})))
}
pub async fn delete_guild_role(auth: AuthUser, Path((id, role_id)): Path<(String,i64)>, State(state): State<AppState>) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_roles WHERE guild_id=? AND id=?")
.bind(&id).bind(role_id).fetch_optional(&state.db).await?;
let name = name.ok_or_else(|| AppError::not_found("role not found"))?;
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE guild_members SET role='member' WHERE guild_id=? AND role=?").bind(&id).bind(&name).execute(&mut *tx).await?;
sqlx::query("DELETE FROM guild_roles WHERE guild_id=? AND id=?").bind(&id).bind(role_id).execute(&mut *tx).await?;
tx.commit().await?;
Ok(Json(json!({"ok":true})))
}
/// Add / Invite member to guild.
pub async fn add_guild_member(
auth: AuthUser,
@@ -479,13 +599,7 @@ pub async fn add_guild_member(
State(state): State<AppState>,
Json(payload): Json<AddMemberPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
}
@@ -536,7 +650,7 @@ pub async fn remove_guild_member(
.await?;
let is_self = auth.uuid == target_uuid;
let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
let is_leader_or_officer = guild_can(&state, &guild_id, &auth.uuid, "kick").await?;
if !is_self && !is_leader_or_officer {
return Err(AppError::forbidden("Cannot kick member without officer privileges"));
@@ -573,8 +687,8 @@ pub async fn create_guild_post(
.fetch_one(&state.db)
.await?;
if !in_guild {
return Err(AppError::forbidden("Must be a guild member to post"));
if !in_guild || !guild_can(&state, &id, &auth.uuid, "post").await? {
return Err(AppError::forbidden("Your guild role cannot post"));
}
let now = chrono::Utc::now().to_rfc3339();
@@ -622,8 +736,8 @@ pub async fn claim_chunk(
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("Your guild role cannot claim land"));
}
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
@@ -694,8 +808,8 @@ pub async fn unclaim_chunk(
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
}
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
@@ -724,8 +838,8 @@ pub async fn unclaim_by_id(auth: AuthUser, Path(claim_id): Path<i64>, State(stat
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
}
sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
@@ -932,8 +1046,8 @@ pub async fn server_claim_index(
if payload.revision.as_deref() == Some(revision.as_str()) {
return Ok(Json(json!({ "revision": revision, "unchanged": true })));
}
let rows: Vec<(String, i32, i32, String, String, String)> = sqlx::query_as(
"SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag
let rows: Vec<(String, i32, i32, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag, g.icon_url
FROM guild_claims gc JOIN guilds g ON g.id = gc.guild_id WHERE gc.server_id = ?",
)
.bind(server.id)
@@ -950,9 +1064,9 @@ pub async fn server_claim_index(
let mut guilds: Vec<Value> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut claims: Vec<Value> = Vec::with_capacity(rows.len());
for (dimension, x, z, guild_id, name, tag) in rows {
for (dimension, x, z, guild_id, name, tag, icon) in rows {
let i = *index.entry(guild_id.clone()).or_insert_with(|| {
guilds.push(json!({ "id": guild_id, "name": name, "tag": tag }));
guilds.push(json!({ "id": guild_id, "name": name, "tag": tag, "icon_url": icon.unwrap_or_default() }));
guilds.len() - 1
});
claims.push(json!([dimension, x, z, i]));
@@ -1033,6 +1147,7 @@ pub async fn server_claim_chunk(
let Some((guild_id, _role)) = member else {
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
};
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
let max_claims: i64 =
sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
@@ -1103,7 +1218,7 @@ pub async fn server_unclaim_chunk(
.fetch_optional(&state.db)
.await?;
if member.is_none() {
if member.is_none() || !guild_can(&state, &guild_id, &payload.uuid, "claim").await? {
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
}
+18 -1
View File
@@ -130,8 +130,11 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
let settings = load_settings(conn).await?;
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
let level_groups: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.luckperms_group') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.luckperms_group') IS NOT NULL AND json_extract(reward_data, '$.luckperms_group') <> '' ORDER BY level_req DESC")
.fetch_all(&mut *conn).await?;
let now = crate::db::now();
let mut assign = Vec::new();
let mut level_assign = Vec::new();
for p in players.into_iter().take(2000) {
let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue };
let groups: Vec<String> = p.groups.iter().map(|g| clean(g, 64).to_lowercase()).filter(|g| !g.is_empty()).take(64).collect();
@@ -155,6 +158,20 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
.bind(&now)
.execute(&mut *conn)
.await?;
if !level_groups.is_empty() {
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
let level = crate::progression::level_from_xp(xp).0;
let target = level_groups.iter().find(|(required, _)| *required <= level).map(|(_, group)| group.to_lowercase());
let mut add = Vec::new();
let mut remove = Vec::new();
for (_, group) in &level_groups {
let key = group.to_lowercase();
if target.as_deref() == Some(key.as_str()) {
if !groups.contains(&key) { add.push(key); }
} else if groups.contains(&key) { remove.push(key); }
}
if !add.is_empty() || !remove.is_empty() { level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove})); }
}
if settings.luckperms_sync == "off" || mapped.is_empty() {
continue;
}
@@ -173,7 +190,7 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
assign.push(json!({ "uuid": uuid, "add": plan.game_add, "remove": plan.game_remove }));
}
}
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign }))
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign, "level_assign": level_assign }))
}
// ---------------------------------------------------------------------------
+38 -1
View File
@@ -1,7 +1,7 @@
//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
use crate::auth::{AdminUser, AuthUser};
use crate::error::AppResult;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
@@ -51,6 +51,9 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
_ => {} // Item/cosmetic entitlements are recorded in granted_rewards.
}
}
let active_title: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='global' AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
.bind(level).fetch_optional(&mut **tx).await?;
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(active_title).bind(uuid).execute(&mut **tx).await?;
Ok(())
}
@@ -278,6 +281,33 @@ pub struct RewardPayload {
pub reward_data: Option<Value>,
}
fn validate_rank_mapping(data: &serde_json::Map<String, Value>) -> AppResult<()> {
if data.get("discord_role_id").is_some_and(|v| !v.is_string() && !v.is_null())
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null()) {
return Err(AppError::bad_request("Rank role mappings must be text"));
}
let discord = data.get("discord_role_id").and_then(Value::as_str).unwrap_or("");
if !discord.is_empty() && (discord.len() > 24 || !discord.bytes().all(|b| b.is_ascii_digit())) {
return Err(AppError::bad_request("Discord role ID must contain digits only"));
}
let group = data.get("luckperms_group").and_then(Value::as_str).unwrap_or("");
if !group.is_empty() && (group.len() > 64 || !group.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')) {
return Err(AppError::bad_request("LuckPerms group must contain letters, digits, _, - or ."));
}
Ok(())
}
async fn refresh_global_titles(state: &AppState) -> AppResult<()> {
let players: Vec<(String, i64)> = sqlx::query_as("SELECT uuid, global_xp FROM user_levels").fetch_all(&state.db).await?;
for (uuid, xp) in players {
let level = level_from_xp(xp).0;
let title: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='global' AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
.bind(level).fetch_optional(&state.db).await?;
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&state.db).await?;
}
Ok(())
}
/// Admin create reward.
pub async fn admin_create_reward(
_admin: AdminUser,
@@ -300,6 +330,7 @@ pub async fn admin_create_reward(
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
validate_rank_mapping(&data_obj)?;
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
let id: i64 = sqlx::query_scalar(
@@ -317,6 +348,8 @@ pub async fn admin_create_reward(
.fetch_one(&state.db)
.await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
@@ -341,6 +374,7 @@ pub async fn admin_update_reward(
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
validate_rank_mapping(&data_obj)?;
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
sqlx::query(
@@ -359,6 +393,8 @@ pub async fn admin_update_reward(
.execute(&state.db)
.await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
@@ -372,5 +408,6 @@ pub async fn admin_delete_reward(
.bind(id)
.execute(&state.db)
.await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+11 -61
View File
@@ -1,16 +1,14 @@
//! Live map endpoints: ingest from game servers, the authenticated Vantage
//! proxy for launchers/admin, and admin status.
//! Live player positions and legacy world-mirror ingest from game servers.
use super::servers::{get_server, GameServer};
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
use crate::livemap::{parse_chunks, Dim, LivePlayer};
use crate::state::AppState;
use axum::body::{Body, Bytes};
use axum::body::Bytes;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::response::Response;
use axum::Json;
use base64::Engine;
use serde::Deserialize;
@@ -199,7 +197,6 @@ fn label(d: Dim) -> &'static str {
async fn info(state: &AppState, id: i64, enabled: bool) -> Value {
let summary = state.livemap.world_summary(id);
let (generator, assets) = state.livemap.generator_ready();
let dims: Vec<Value> = summary["dimensions"]
.as_array()
.into_iter()
@@ -209,43 +206,35 @@ async fn info(state: &AppState, id: i64, enabled: bool) -> Value {
Some(json!({ "id": dim.id(), "slug": dim.slug(), "label": label(dim), "available": d["regions"].as_u64().unwrap_or(0) > 0 }))
})
.collect();
let has_world = summary["level_dat"].as_bool().unwrap_or(false) && dims.iter().any(|d| d["available"] == true);
let message = if !enabled {
"Live Map is off for this server."
} else if !generator || !assets {
"The map generator isn't set up on the panel yet."
} else if !has_world {
"Waiting for the game server to upload its world."
"Live player positions are off for this server."
} else {
""
};
json!({
"enabled": enabled,
"ready": enabled && generator && assets && has_world,
"ready": enabled,
"message": message,
"base": format!("/api/livemap/{id}"),
"dimensions": dims,
"players": state.livemap.live_players(id).len(),
})
}
/// What the launcher needs to decide whether to offer the Live Map button.
pub async fn viewer_info(_: AuthUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
/// A signed-in player's own live position for the territory map.
pub async fn viewer_info(AuthUser(auth): AuthUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
Ok(Json(info(&state, id, server.live_map_enabled).await))
let mut result = info(&state, id, server.live_map_enabled).await;
result["me"] = serde_json::to_value(state.livemap.live_players(id).into_iter()
.find(|player| player.uuid.eq_ignore_ascii_case(&auth.uuid)))?;
Ok(Json(result))
}
pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
let mut v = info(&state, id, server.live_map_enabled).await;
let (generator, assets) = state.livemap.generator_ready();
let stats = state.livemap.stats(id);
v["generator"] = json!({ "installed": generator, "path": state.livemap.generator_path() });
v["assets"] = json!({ "configured": assets, "path": state.livemap.assets_path() });
v["world"] = state.livemap.world_summary(id);
v["stats"] = serde_json::to_value(stats)?;
v["running"] = json!(state.livemap.sidecars_running(id).await);
v["last_error"] = json!(state.livemap.last_error(id));
v["roster"] = serde_json::to_value(state.livemap.live_players(id))?;
Ok(Json(v))
}
@@ -255,42 +244,3 @@ pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id):
state.livemap.reset(id).await?;
Ok(Json(json!({ "ok": true })))
}
/// Authenticated reverse proxy to the per-server Vantage sidecar. Clients
/// use `…/api/livemap/{server}/{dimension}/v1/worlds/default/manifest.json`
/// and everything the manifest references.
pub async fn proxy(
_: AuthUser,
State(state): State<AppState>,
Path((id, dim, path)): Path<(i64, String, String)>,
headers: HeaderMap,
) -> AppResult<Response> {
let server = get_server(&state, id).await?;
require_enabled(server.live_map_enabled)?;
let dim = dim_of(&dim)?;
if path.split('/').any(|s| s == ".." || s.is_empty() || s.contains('\\')) || !path.starts_with("v1/worlds/default/") {
return Err(AppError::not_found("not found"));
}
if path == "v1/worlds/default/players.json" {
let mut resp = Json(state.livemap.players_json(id, dim)).into_response();
resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
return Ok(resp);
}
let (port, token) = state.livemap.ensure_sidecar(id, dim).await.map_err(|m| AppError::new(StatusCode::SERVICE_UNAVAILABLE, m))?;
let mut req = state.http.get(format!("http://127.0.0.1:{port}/{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(120));
if let Some(etag) = headers.get(header::IF_NONE_MATCH) {
req = req.header(header::IF_NONE_MATCH, etag);
}
let upstream = req.send().await.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("map generator unreachable: {e}")))?;
let mut out = Response::builder().status(upstream.status());
for name in [header::CONTENT_TYPE, header::ETAG, header::CACHE_CONTROL, header::LAST_MODIFIED] {
if let Some(v) = upstream.headers().get(&name) {
out = out.header(name, v);
}
}
// Tiles are immutable per etag; make sure shared caches never keep them.
Ok(out
.header(header::VARY, "Authorization")
.body(Body::from_stream(upstream.bytes_stream()))
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?)
}
+306
View File
@@ -0,0 +1,306 @@
//! Things you can do from the map: ask a player to teleport, message them in game, and invite them to your guild.
//!
//! Teleports and messages are queued for the game server (`server_actions`), which collects them every few seconds
//! and carries them out with its normal rules. Guild invitations are real invitations: the other player accepts
//! or declines, in the launcher or in game.
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::routes::servers::{get_server, GameServer};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::SqliteConnection;
const ACTION_BURST: i64 = 6;
const ACTION_WINDOW_SECS: i64 = 30;
fn ago(secs: i64) -> String {
(chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
async fn online_on(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<bool> {
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)").bind(server_id).bind(uuid).fetch_one(&mut *conn).await?)
}
/// Keep only printable characters, collapse whitespace, and cut to `max`.
fn clean(text: &str, max: usize) -> String {
text.chars().filter(|c| c.is_whitespace() || !c.is_control()).map(|c| if c.is_whitespace() { ' ' } else { c }).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ").chars().take(max).collect()
}
#[derive(Deserialize)]
pub struct MapAction {
/// `tpa` or `message`.
pub action: String,
pub target_uuid: String,
#[serde(default)]
pub text: String,
}
/// `POST /servers/{id}/map/actions`: a signed-in player acts on another player they clicked on the map.
pub async fn map_action(auth: AuthUser, Path(server_id): Path<i64>, State(state): State<AppState>, Json(a): Json<MapAction>) -> AppResult<Json<Value>> {
get_server(&state, server_id).await?;
if a.target_uuid == auth.uuid {
return Err(AppError::bad_request("That's you"));
}
let mut conn = state.db.acquire().await?;
let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')").bind(&a.target_uuid).fetch_one(&mut *conn).await?;
if !active {
return Err(AppError::not_found("Player not found"));
}
let recent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_actions WHERE from_uuid = ? AND created_at > ?")
.bind(&auth.uuid)
.bind(ago(ACTION_WINDOW_SECS))
.fetch_one(&mut *conn)
.await?;
if recent >= ACTION_BURST {
return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Slow down a little and try again"));
}
if !online_on(&mut conn, server_id, &a.target_uuid).await? {
return Err(AppError::bad_request("They aren't online on this server right now"));
}
let text = match a.action.as_str() {
"tpa" => {
if !online_on(&mut conn, server_id, &auth.uuid).await? {
return Err(AppError::bad_request("Join the server first: a teleport request needs you in game"));
}
String::new()
}
"message" => {
let text = clean(&a.text, 200);
if text.is_empty() {
return Err(AppError::bad_request("Write a message first"));
}
text
}
_ => return Err(AppError::bad_request("unknown action")),
};
sqlx::query("INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(server_id)
.bind(&a.action)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(&a.target_uuid)
.bind(&text)
.bind(crate::db::now())
.execute(&mut *conn)
.await?;
Ok(Json(json!({ "ok": true })))
}
/// `POST /api/server/v1/actions/poll`: the game server collects what is waiting for it (each action is handed out once).
pub async fn poll_actions(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Value>> {
let mut tx = state.db.begin().await?;
// Stale ones (a server that was offline) are dropped rather than replayed minutes later.
sqlx::query("UPDATE server_actions SET taken_at = ? WHERE server_id = ? AND taken_at IS NULL AND created_at < ?")
.bind(crate::db::now())
.bind(server.id)
.bind(ago(120))
.execute(&mut *tx)
.await?;
let rows: Vec<(i64, String, String, String, String, String)> = sqlx::query_as(
"SELECT id, kind, from_uuid, from_name, to_uuid, text FROM server_actions WHERE server_id = ? AND taken_at IS NULL ORDER BY id LIMIT 50",
)
.bind(server.id)
.fetch_all(&mut *tx)
.await?;
let now = crate::db::now();
for (id, ..) in &rows {
sqlx::query("UPDATE server_actions SET taken_at = ? WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
}
// Old handled rows are only useful for rate limiting.
sqlx::query("DELETE FROM server_actions WHERE taken_at IS NOT NULL AND created_at < ?").bind(ago(3600)).execute(&mut *tx).await?;
tx.commit().await?;
let actions: Vec<Value> = rows
.into_iter()
.map(|(id, kind, from_uuid, from_name, to_uuid, text)| json!({ "id": id, "kind": kind, "from_uuid": from_uuid, "from_name": from_name, "to_uuid": to_uuid, "text": text }))
.collect();
Ok(Json(json!({ "actions": actions })))
}
/// Queue a line of chat for a player on every server where they are online.
pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str) -> AppResult<()> {
let servers: Vec<i64> = sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?;
for id in servers {
sqlx::query("INSERT INTO server_actions (server_id, kind, to_uuid, text, created_at) VALUES (?, 'notify', ?, ?, ?)")
.bind(id)
.bind(to_uuid)
.bind(text)
.bind(crate::db::now())
.execute(&mut *conn)
.await?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Guild invitations
// ---------------------------------------------------------------------------
/// Invite `target_uuid` to `guild_id` on behalf of `inviter_uuid` (who must lead or officiate it).
pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &str, guild_id: &str, target_uuid: &str) -> AppResult<Value> {
if inviter_uuid == target_uuid {
return Err(AppError::bad_request("You can't invite yourself"));
}
let mut conn = state.db.acquire().await?;
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?;
let allowed = match role.as_deref() {
Some("leader" | "officer") => true,
Some(custom) => sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
.bind(guild_id).bind(custom).fetch_optional(&mut *conn).await?.unwrap_or(0) != 0,
None => false,
};
if !allowed {
return Err(AppError::forbidden("Your guild role cannot invite players"));
}
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?;
let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
let (name, tag, instance): (String, String, String) =
sqlx::query_as("SELECT name, tag, instance_id FROM guilds WHERE id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
let taken: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)",
)
.bind(target_uuid)
.bind(&instance)
.fetch_one(&mut *conn)
.await?;
if taken {
return Err(AppError::bad_request(format!("{target_name} is already in a guild here")));
}
let recent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_invites WHERE inviter_uuid = ? AND created_at > ?")
.bind(inviter_uuid)
.bind(ago(60))
.fetch_one(&mut *conn)
.await?;
if recent >= 10 {
return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Too many invitations; wait a minute"));
}
// One open invitation per guild and player.
sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'").bind(guild_id).bind(target_uuid).execute(&mut *conn).await?;
let id: i64 = sqlx::query_scalar("INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id")
.bind(guild_id)
.bind(inviter_uuid)
.bind(inviter_name)
.bind(target_uuid)
.bind(crate::db::now())
.fetch_one(&mut *conn)
.await?;
tell_player(&mut conn, target_uuid, &format!("\u{a7}e{inviter_name} \u{a7}ainvited you to join \u{a7}6[{tag}] {name}\u{a7}a. \u{a7}7Type \u{a7}e/guild accept \u{a7}7or \u{a7}c/guild decline\u{a7}7, or answer in the launcher.")).await?;
Ok(json!({ "ok": true, "id": id, "player": target_name }))
}
#[derive(Deserialize)]
pub struct InvitePayload {
pub uuid: String,
}
pub async fn send_invite(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<InvitePayload>) -> AppResult<Json<Value>> {
Ok(Json(create_invite(&state, &auth.uuid, &auth.username, &guild_id, &p.uuid).await?))
}
fn invite_json(row: (i64, String, String, String, String, String, String)) -> Value {
let (id, guild_id, name, tag, icon, inviter, at) = row;
json!({ "id": id, "guild_id": guild_id, "guild_name": name, "guild_tag": tag, "icon_url": icon, "inviter": inviter, "created_at": at })
}
async fn pending_for(state: &AppState, uuid: &str) -> AppResult<Vec<Value>> {
let rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT i.id, i.guild_id, g.name, g.tag, COALESCE(g.icon_url, ''), i.inviter_name, i.created_at
FROM guild_invites i JOIN guilds g ON g.id = i.guild_id WHERE i.target_uuid = ? AND i.status = 'pending' ORDER BY i.id DESC LIMIT 20",
)
.bind(uuid)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(invite_json).collect())
}
pub async fn my_invites(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!(pending_for(&state, &auth.uuid).await?)))
}
/// Accept or decline. `invite` is an id, or empty to take the newest open one.
pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i64>, tag: Option<&str>, accept: bool) -> AppResult<Value> {
let mut tx = state.db.begin().await?;
let row: Option<(i64, String)> = match (invite, tag) {
(Some(id), _) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'").bind(id).bind(uuid).fetch_optional(&mut *tx).await?,
(None, Some(tag)) => sqlx::query_as(
"SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id
WHERE i.target_uuid = ? AND i.status = 'pending' AND g.tag = ? COLLATE NOCASE ORDER BY i.id DESC LIMIT 1",
)
.bind(uuid)
.bind(tag)
.fetch_optional(&mut *tx)
.await?,
(None, None) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1").bind(uuid).fetch_optional(&mut *tx).await?,
};
let Some((id, guild_id)) = row else { return Err(AppError::not_found("You have no open guild invitation")) };
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?").bind(if accept { "accepted" } else { "declined" }).bind(id).execute(&mut *tx).await?;
let (gname, gtag): (String, String) = sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
if accept {
let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)")
.bind(&guild_id)
.bind(uuid)
.bind(name)
.bind(crate::db::now())
.execute(&mut *tx)
.await;
if res.is_err() {
// The database refuses a second guild on the same instance.
return Err(AppError::bad_request("You're already in a guild here. Leave it first."));
}
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)").bind(uuid).bind(crate::db::now()).execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(json!({ "ok": true, "accepted": accept, "guild": gname, "tag": gtag }))
}
pub async fn accept_invite(auth: AuthUser, Path(id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(respond(&state, &auth.uuid, &auth.username, Some(id), None, true).await?))
}
pub async fn decline_invite(auth: AuthUser, Path(id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(respond(&state, &auth.uuid, &auth.username, Some(id), None, false).await?))
}
// ---- the same, asked by a game server on a player's behalf ---------------------------------
#[derive(Deserialize)]
pub struct ServerInvite {
/// The player asking (inviter, or the invited player when answering).
pub uuid: String,
#[serde(default)]
pub target: String,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub accept: bool,
}
pub async fn server_invite_send(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let inviter: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let inviter = inviter.ok_or_else(|| AppError::not_found("Account not found"))?;
let target: Option<String> = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE").bind(p.target.trim()).fetch_optional(&state.db).await?;
let target = target.ok_or_else(|| AppError::not_found("No player with that name"))?;
let guild: Option<String> = sqlx::query_scalar(
"SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ? AND gm.role IN ('leader', 'officer')",
)
.bind(&p.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?;
let guild = guild.ok_or_else(|| AppError::forbidden("You must lead or officiate a guild to invite players"))?;
Ok(Json(create_invite(&state, &p.uuid, &inviter, &guild, &target).await?))
}
pub async fn server_invites(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? })))
}
pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let name: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let name = name.ok_or_else(|| AppError::not_found("Account not found"))?;
let tag = if p.tag.trim().is_empty() { None } else { Some(p.tag.trim().to_string()) };
Ok(Json(respond(&state, &p.uuid, &name, None, tag.as_deref(), p.accept).await?))
}
+15 -1
View File
@@ -11,6 +11,7 @@ pub mod discord;
pub mod integrations;
pub mod landing;
pub mod livemap;
pub mod map;
pub mod leveling;
pub mod meta;
pub mod progression;
@@ -53,6 +54,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/servers/public", get(servers::public_servers))
.route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard))
.route("/servers/{id}/livemap", get(livemap::viewer_info))
.route("/servers/{id}/map/actions", post(map::map_action))
.route("/guilds/invites", get(map::my_invites))
.route("/guilds/invites/{id}/accept", post(map::accept_invite))
.route("/guilds/invites/{id}/decline", post(map::decline_invite))
.route("/guilds/{id}/invites", post(map::send_invite))
.route("/leaderboard", get(servers::global_leaderboard))
.route("/landing", get(landing::public_landing))
.route("/launcher/authlib-injector.json", get(account::authlib_index))
@@ -78,6 +84,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
.route("/guilds/{id}/members", get(guilds::get_guild_members).post(guilds::add_guild_member))
.route("/guilds/{id}/members/{uuid}/role", put(guilds::assign_guild_role))
.route("/guilds/{id}/roles", get(guilds::list_guild_roles).post(guilds::create_guild_role))
.route("/guilds/{id}/roles/{role_id}", delete(guilds::delete_guild_role))
.route("/guilds/{id}/requests", get(guilds::list_join_requests).post(guilds::request_join))
.route("/guilds/{id}/requests/{uuid}/respond", post(guilds::respond_join_request))
.route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member))
.route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post))
.route("/guilds/{id}/wallet", get(guilds::guild_wallet))
@@ -195,6 +206,10 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/create", post(guilds::server_create_guild))
.route("/guilds/leave", post(guilds::server_guild_leave))
.route("/integrations/report", post(integrations::server_report))
.route("/actions/poll", post(map::poll_actions))
.route("/guilds/invite/send", post(map::server_invite_send))
.route("/guilds/invite/list", post(map::server_invites))
.route("/guilds/invite/respond", post(map::server_invite_respond))
.route("/player/info", post(dev_api::player_info))
.route("/player/xp", post(dev_api::add_xp))
.route("/player/quest-objective", post(dev_api::quest_objective))
@@ -226,7 +241,6 @@ pub fn api(state: &AppState) -> Router<AppState> {
.layer(DefaultBodyLimit::max(10 * 1024 * 1024));
Router::new()
.route("/api/livemap/{id}/{dim}/{*path}", get(livemap::proxy))
.nest("/api/v1", launcher)
.nest("/api/server/v1", game.merge(livemap_game))
.nest("/api/admin", admin)
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more