Merge remote work into the SCOPENET Map branch (livemap/Vantage stay removed)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-10-01 00:32:34 +00:00
commit 30b1d92437
75 files changed
+1464 -2034

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. # Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
SCOPENET_TRUSTED_PROXIES= SCOPENET_TRUSTED_PROXIES=
# Live Map: optional static Vantage Linux release to bundle, and memory for the panel.
VANTAGE_URL=
PANEL_MEMORY=256M PANEL_MEMORY=256M
Generated
+4 -4
View File
@@ -3999,7 +3999,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "scopenet-core" name = "scopenet-core"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@@ -4021,7 +4021,7 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-launcher" name = "scopenet-launcher"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@@ -4047,7 +4047,7 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-panel" name = "scopenet-panel"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -4083,7 +4083,7 @@ dependencies = [
[[package]] [[package]]
name = "scopenet-shared" name = "scopenet-shared"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"md-5", "md-5",
"serde", "serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"] members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package] [workspace.package]
version = "0.4.0" version = "0.5.0"
edition = "2021" edition = "2021"
repository = "https://github.com/scopeddlol/SCOPENET-MC" 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. # Data dir owned by the non-root runtime user.
RUN mkdir -p /out/data && chown 65532:65532 /out/data RUN mkdir -p /out/data && chown 65532:65532 /out/data
# ---- 2b. Vantage generator (optional) ----------------------------------------
# The live map renders with Vantage. Point VANTAGE_URL at a static Linux
# release archive (tar.gz or zip) for the target arch to bake it into the image;
# leave it empty to mount your own binary at /opt/vantage/vantage instead.
FROM --platform=$BUILDPLATFORM alpine:3 AS vantage
ARG VANTAGE_URL=""
RUN apk add --no-cache curl tar unzip && mkdir -p /out/vantage /out/assets \
&& if [ -n "$VANTAGE_URL" ]; then \
curl -fsSL "$VANTAGE_URL" -o /tmp/vantage.pkg \
&& (tar -xzf /tmp/vantage.pkg -C /out/vantage 2>/dev/null || unzip -q /tmp/vantage.pkg -d /out/vantage) \
&& find /out/vantage -type f -name vantage -exec chmod 755 {} + \
&& (f=$(find /out/vantage -type f -name vantage | head -n1) && [ -n "$f" ] && [ "$f" != /out/vantage/vantage ] && mv "$f" /out/vantage/vantage || true); \
fi
# ---- 3. Runtime -------------------------------------------------------------- # ---- 3. Runtime --------------------------------------------------------------
FROM scratch FROM scratch
LABEL org.opencontainers.image.title="SCOPENET Panel" \ LABEL org.opencontainers.image.title="SCOPENET Panel" \
@@ -58,12 +44,9 @@ LABEL org.opencontainers.image.title="SCOPENET Panel" \
COPY --from=build /scopenet-panel /scopenet-panel COPY --from=build /scopenet-panel /scopenet-panel
COPY --from=web /src/panel/web/dist /web COPY --from=web /src/panel/web/dist /web
COPY --from=build --chown=65532:65532 /out/data /data COPY --from=build --chown=65532:65532 /out/data /data
COPY --from=vantage /out/vantage /opt/vantage
ENV SCOPENET_BIND=0.0.0.0:8080 \ ENV SCOPENET_BIND=0.0.0.0:8080 \
SCOPENET_DATA_DIR=/data \ SCOPENET_DATA_DIR=/data \
SCOPENET_WEB_DIR=/web \ SCOPENET_WEB_DIR=/web \
SCOPENET_VANTAGE_BIN=/opt/vantage/vantage \
SCOPENET_VANTAGE_ASSETS=/data/livemap/assets \
RUST_LOG=info RUST_LOG=info
USER 65532:65532 USER 65532:65532
VOLUME ["/data"] VOLUME ["/data"]
-8
View File
@@ -10,9 +10,6 @@ services:
image: scopenet-panel:local image: scopenet-panel:local
build: build:
context: . context: .
args:
# Optional: URL of a static Vantage Linux release to bundle.
VANTAGE_URL: ${VANTAGE_URL:-}
container_name: scopenet-panel container_name: scopenet-panel
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -29,10 +26,6 @@ services:
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
PUBLIC_URL: ${PUBLIC_URL:-} PUBLIC_URL: ${PUBLIC_URL:-}
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-} SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
# Live Map: Vantage generator + Minecraft client assets (see docs/live-map.md).
SCOPENET_VANTAGE_BIN: ${SCOPENET_VANTAGE_BIN:-/opt/vantage/vantage}
SCOPENET_VANTAGE_ASSETS: ${SCOPENET_VANTAGE_ASSETS:-/data/livemap/assets}
SCOPENET_VANTAGE_ARGS: ${SCOPENET_VANTAGE_ARGS:-}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-info}
volumes: volumes:
- panel-data:/data - panel-data:/data
@@ -40,7 +33,6 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
# Raise this if you enable Live Map: Vantage bakes tiles in memory.
memory: ${PANEL_MEMORY:-256M} memory: ${PANEL_MEMORY:-256M}
volumes: volumes:
+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 `docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
blank on first start, the generated password appears in the panel logs. 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 To show a 2D world map and claim land in the launcher, install BlueMap on the
server and configure its manifest URL in the panel. See game server and configure its public web address in the panel. See
[Live map setup](live-map.md). Vantage needs access to the game world's saved [Live map setup](live-map.md).
files, so it runs alongside the game server rather than inside the panel
container.
The named `panel-data` volume holds the database, uploads, hosted launcher The named `panel-data` volume holds the database, uploads, hosted launcher
downloads, and the generated JWT signing key. Preserve that volume when downloads, and the generated JWT signing key. Preserve that volume when
+11 -65
View File
@@ -1,75 +1,21 @@
# Live Map (central Vantage) # Live map and land claims
> **Looking for the simplest map?** Use BlueMap: [bluemap.md](bluemap.md). It needs nothing on the panel, adds guild claims, pins and a player panel, and shows inside the launcher. 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.
>
> **Setting up the panel-hosted Vantage map?** Follow [vantage-setup.md](vantage-setup.md). This page is the overview.
The panel hosts a [Vantage](https://github.com/thoughts-on-things/vantage-mc) 3D map 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/`.
**for every game server**, so nobody has to run Vantage next to Minecraft.
``` ## Center on the player
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
```
1. **Collect** – the SCOPENET Paper plugin / Fabric / Forge mod reads the world's 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.
region files (only chunks newer than what the panel already has) and sends them to
the panel over HTTPS. Player positions stream over a WebSocket (HTTPS fallback).
2. **Store & render** – the panel keeps a region-file mirror per server under
`<data>/livemap/<server id>/world` and runs a `vantage server` sidecar over it on
demand (stopped again after 15 idle minutes). Sidecars listen on loopback only.
3. **Serve** – `GET /api/livemap/<server>/<dimension>/v1/worlds/default/…` proxies the
sidecar to signed-in launcher/admin sessions. Player markers come straight from
memory. One map per server and dimension (`overworld`, `the_nether`, `the_end`).
4. **View** – the launcher checks `GET /api/v1/servers/<id>/livemap`; when Live Map is
on for a server linked to the instance, the instance page shows a **Live Map**
button that opens the map inside the launcher (and the Guilds territory view uses
it too). Admins see the same map on *Servers → your server*.
Chunk data only reaches the panel when the game server has written it, so the map The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable.
follows autosaves (and `/save-all`) rather than every block change.
## Turn it on ## Claim controls
1. **Servers → your server → Settings → Live Map** (on by default for new servers). 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.
2. Update the plugin/mod. `livemap.enabled: true` (Paper `config.yml`) or
`livemap.enabled=true` (`config/scopenet.properties`) is the default; set it to
`false` to keep a server off the map locally.
3. The server page shows a checklist: generator, assets, world data, player positions.
## Panel requirements 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 | 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.
| --- | --- |
| `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.
+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.
+2 -4
View File
@@ -64,10 +64,8 @@ essentials:
rtp-radius: 2500 rtp-radius: 2500
livemap: livemap:
# Send world chunks and player positions to the panel's central 3D Live Map. # Send live player positions to the panel so the launcher can center its
# Nothing runs on this server; the panel renders it. Needs "Live Map" switched on # BlueMap territory view. Configure BlueMap's public address in the panel.
# for this server in the panel, and Vantage installed on the panel.
# Setup guide: docs/vantage-setup.md
enabled: true enabled: true
economy: economy:
@@ -36,7 +36,9 @@ public record ScopenetPlayer(
QuestProgress weeklyQuests, QuestProgress weeklyQuests,
String joined, String joined,
String rankGroup, String rankGroup,
String rankPrefix) { String rankDisplay,
String rankPrefix,
String rankSuffix) {
public boolean inGuild() { return guild != null; } public boolean inGuild() { return guild != null; }
+6 -1
View File
@@ -4,8 +4,13 @@
repositories { repositories {
maven { url = 'https://repo.bluecolored.de/releases' } maven { url = 'https://repo.bluecolored.de/releases' }
} }
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies { dependencies {
implementation project(':common') implementation project(':common')
compileOnly 'de.bluecolored.bluemap:BlueMapAPI:2.7.4' compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.10.1'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
allprojects { allprojects {
group = 'net.scopenet' group = 'net.scopenet'
version = providers.gradleProperty('releaseVersion').getOrElse('0.4.0') version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
repositories { mavenCentral() } repositories { mavenCentral() }
} }
subprojects { subprojects {
@@ -144,13 +144,10 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler; this.notificationHandler = handler;
} }
/** /** Start sending player positions for the in-app BlueMap territory view. */
* Start mirroring this server's world and player positions to the panel's public void enableLiveMap() {
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
*/
public void enableLiveMap(java.nio.file.Path worldRoot) {
if (closed || liveMap != null) return; if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client, worldRoot, log); liveMap = new LiveMapSync(client);
liveMap.start(); liveMap.start();
} }
@@ -160,12 +157,6 @@ public final class Integration implements AutoCloseable {
if (map != null) map.offerPlayers(players); 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) { public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
return client.checkChunk(dimension, chunkX, chunkZ, uuid); return client.checkChunk(dimension, chunkX, chunkZ, uuid);
} }
@@ -1,363 +1,70 @@
package net.scopenet.integration; package net.scopenet.integration;
import com.google.gson.*; import com.google.gson.JsonArray;
import java.io.ByteArrayOutputStream; import com.google.gson.JsonObject;
import java.io.IOException; import java.util.List;
import java.net.URI; import java.util.concurrent.Executors;
import java.net.http.WebSocket; import java.util.concurrent.ScheduledExecutorService;
import java.nio.ByteBuffer; import java.util.concurrent.TimeUnit;
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;
/** /** Sends live player positions to the panel for the in-app BlueMap territory view. */
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
* already-compressed Anvil payloads read straight from the region files) over
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
* Nothing here touches the server thread except {@link #offerPlayers}.
*/
public final class LiveMapSync implements AutoCloseable { public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {} 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 PanelClient client;
private final Path root; private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
private final Consumer<String> log; Thread thread = new Thread(task, "scopenet-positions");
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> { thread.setDaemon(true);
Thread t = new Thread(r, "scopenet-livemap"); return thread;
t.setDaemon(true);
return t;
}); });
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
private final Map<String, int[]> known = new HashMap<>();
private final Set<String> manifestLoaded = new HashSet<>();
private final AtomicBoolean scanQueued = new AtomicBoolean();
private volatile boolean panelEnabled;
private volatile boolean closed;
private volatile List<Player> players = List.of(); private volatile List<Player> players = List.of();
private volatile boolean playersChanged; private volatile boolean changed;
private volatile WebSocket socket; private volatile boolean closed;
private volatile long socketRetryAt; private long lastSent;
private long nextScan;
private long configCheckAt;
private long levelModified = -1;
private long lastPlayersSent;
private long chunkIntervalSecs = 20;
private boolean warnedMissing;
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) { public LiveMapSync(PanelClient client) { this.client = client; }
this.client = client;
this.root = root;
this.log = log;
}
public void start() { public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) { public void offerPlayers(List<Player> next) {
if (!next.equals(players)) { if (!next.equals(players)) {
players = List.copyOf(next); players = List.copyOf(next);
playersChanged = true; changed = true;
} }
} }
/** A save just happened; scan soon instead of waiting for the next interval. */ private void sendSafely() {
public void requestScan() { if (closed) return;
if (closed || !scanQueued.compareAndSet(false, true)) 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 { try {
worker.schedule(() -> { client.post("livemap/players", body);
scanQueued.set(false); changed = false;
nextScan = 0; lastSent = now;
scanSafely(); } catch (InterruptedException e) {
}, 3, TimeUnit.SECONDS); Thread.currentThread().interrupt();
} catch (RejectedExecutionException e) { } catch (Exception ignored) {
scanQueued.set(false); // Keep the latest snapshot and retry on the next interval.
} }
} }
@Override public void close() { @Override public void close() {
closed = true; closed = true;
WebSocket ws = socket;
if (ws != null) ws.abort();
worker.shutdownNow(); 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.
}
}
} }
@@ -33,24 +33,13 @@ public final class Bridge {
})); }));
integration.startClaimSync(); integration.startClaimSync();
Path config = Path.of("config/scopenet.properties"); Path config = Path.of("config/scopenet.properties");
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder()); if (Settings.liveMapEnabled(config)) integration.enableLiveMap();
} catch (Exception e) { } catch (Exception e) {
// Throwing aborts startup; silently disabling an auth mod is unsafe. // Throwing aborts startup; silently disabling an auth mod is unsafe.
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e); 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) { public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
Integration current = integration; Integration current = integration;
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE); if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
+1 -1
View File
@@ -24,6 +24,6 @@ jar {
from project(':common').sourceSets.main.output from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output from project(':api').sourceSets.main.output
from project(':bluemap').sourceSets.main.output from project(':bluemap').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } } from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA' 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();
}
}
@@ -27,6 +27,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private ClientLinkBridge clientLink; private ClientLinkBridge clientLink;
private EssentialsHandler essentialsHandler; private EssentialsHandler essentialsHandler;
private GuildHandler guildHandler; private GuildHandler guildHandler;
private ChatFormatter chatFormatter;
@Override public void onEnable() { @Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid. // Install the deny-by-default listener even if configuration is invalid.
@@ -50,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. // Claim protection runs from a local copy of the panel's claims, refreshed in the background.
integration.startClaimSync(); 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)) { if (getConfig().getBoolean("livemap.enabled", true)) {
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath()); integration.enableLiveMap();
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20); Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
} }
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager, // Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
// and receive level-ups, achievements and guild changes as ordinary Bukkit events. // and receive level-ups, achievements and guild changes as ordinary Bukkit events.
api = new ScopenetApiImpl(integration.client()); api = new ScopenetApiImpl(integration.client());
chatFormatter = new ChatFormatter(api);
ScopenetApiProvider.register(api); ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal); getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
// Optional client mod (HUD, claim overlay, shop and market windows). // Optional client mod (HUD, claim overlay, shop and market windows).
@@ -82,9 +84,16 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
EssentialsHandler essentials = new EssentialsHandler(this); EssentialsHandler essentials = new EssentialsHandler(this);
essentialsHandler = essentials; essentialsHandler = essentials;
getServer().getPluginManager().registerEvents(essentials, this); 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); 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 // Register Economy commands & GUI
{ {
@@ -102,6 +111,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
bindCommand("guild", guilds); bindCommand("guild", guilds);
bindCommand("claim", guilds); bindCommand("claim", guilds);
bindCommand("unclaim", 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. // Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it.
@@ -118,7 +128,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED")); getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED"));
getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED")); getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED"));
} catch (Exception e) { } 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); for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
} }
} }
@@ -166,11 +176,6 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
integration.liveMapPlayers(list); 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) { public static String dimension(World world) {
return world.getKey().toString(); return world.getKey().toString();
} }
@@ -357,8 +362,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
if (claimed(event.getBlock())) event.setCancelled(true); if (claimed(event.getBlock())) event.setCancelled(true);
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void chat(AsyncPlayerChatEvent event) { 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"); add(event.getPlayer(), "messages");
} }
@@ -27,8 +27,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
private final ScopenetPlugin plugin; private final ScopenetPlugin plugin;
private final File homesFile; private final File homesFile;
private final File warpsFile; private final File warpsFile;
private final File warpRequestsFile;
private final YamlConfiguration homesConfig; private final YamlConfiguration homesConfig;
private final YamlConfiguration warpsConfig; private final YamlConfiguration warpsConfig;
private final YamlConfiguration warpRequestsConfig;
private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>(); private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>();
private final Map<String, Location> warps = 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.plugin = plugin;
this.homesFile = new File(plugin.getDataFolder(), "homes.yml"); this.homesFile = new File(plugin.getDataFolder(), "homes.yml");
this.warpsFile = new File(plugin.getDataFolder(), "warps.yml"); this.warpsFile = new File(plugin.getDataFolder(), "warps.yml");
this.warpRequestsFile = new File(plugin.getDataFolder(), "warp_requests.yml");
this.homesConfig = YamlConfiguration.loadConfiguration(homesFile); this.homesConfig = YamlConfiguration.loadConfiguration(homesFile);
this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile); this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile);
this.warpRequestsConfig = YamlConfiguration.loadConfiguration(warpRequestsFile);
loadHomes(); loadHomes();
loadWarps(); 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) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) { public void onTeleport(PlayerTeleportEvent event) {
if (event.getFrom() != null && event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) { 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 "tpdeny" -> handleTpDeny(player);
case "rtp" -> handleRtp(player); case "rtp" -> handleRtp(player);
case "warp" -> handleWarp(player, args); 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); case "playtime" -> handlePlaytime(player, args);
default -> { return false; } default -> { return false; }
} }
@@ -389,6 +404,88 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.GREEN + "Teleported to warp " + ChatColor.YELLOW + warpName + ChatColor.GREEN + "."); 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) { private void openWarpPicker(Player player) {
Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE); Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE);
int slot = 11; int slot = 11;
@@ -20,6 +20,8 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent; 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.Inventory;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
@@ -34,6 +36,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
private final File guildHomesFile; private final File guildHomesFile;
private final YamlConfiguration guildHomesConfig; private final YamlConfiguration guildHomesConfig;
private final Map<String, Location> guildHomes = new ConcurrentHashMap<>(); 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"; 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); handleUnclaim(player);
return true; 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 (cmd.equals("guild") || cmd.equals("g")) {
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) { if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
@@ -211,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) { private void handleUnclaim(Player player) {
Chunk chunk = player.getLocation().getChunk(); Chunk chunk = player.getLocation().getChunk();
String dim = ScopenetPlugin.dimension(player.getWorld()); 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(); JsonObject config = answer.has("config") && answer.get("config").isJsonObject() ? answer.getAsJsonObject("config") : new JsonObject();
if (config.has("mode")) lastMode = config.get("mode").getAsString(); 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 (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) { } catch (Exception e) {
context.log().fine("LuckPerms report failed: " + e.getMessage()); 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")), 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"), 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"), (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) { static ScopenetGuild guild(JsonObject j) {
@@ -52,9 +52,8 @@ essentials:
rtp-radius: 2500 rtp-radius: 2500
livemap: livemap:
# Send world chunks and live player positions to the panel's central Live Map. # Send live player positions to the panel for the launcher BlueMap claim view.
# Players open it from the launcher; you don't run Vantage on this server. # Configure BlueMap's public address and enable live positions in the panel.
# It also needs "Live Map" switched on for this server in the panel.
enabled: true enabled: true
economy: economy:
@@ -50,10 +50,37 @@ commands:
permission: scopenet.command.warp permission: scopenet.command.warp
description: Teleport to a server warp or open the warps GUI description: Teleport to a server warp or open the warps GUI
aliases: [warps] 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: playtime:
permission: scopenet.command.playtime permission: scopenet.command.playtime
description: Check total and session playtime description: Check total and session playtime
aliases: [ontime] 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: balance:
permission: scopenet.command.balance permission: scopenet.command.balance
description: Check your server economy balance description: Check your server economy balance
@@ -94,6 +121,9 @@ commands:
unclaim: unclaim:
permission: scopenet.command.unclaim permission: scopenet.command.unclaim
description: Unclaim the current chunk description: Unclaim the current chunk
autoclaim:
permission: scopenet.command.claim
description: Claim chunks as you walk into them
permissions: permissions:
@@ -137,9 +167,21 @@ permissions:
scopenet.command.warp: scopenet.command.warp:
description: Use /warp description: Use /warp
default: true 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: scopenet.command.playtime:
description: Use /playtime description: Use /playtime
default: true 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: scopenet.command.balance:
description: Use /balance description: Use /balance
default: true default: true
@@ -176,6 +218,9 @@ permissions:
scopenet.command.unclaim: scopenet.command.unclaim:
description: Use /unclaim description: Use /unclaim
default: true default: true
scopenet.chat.color:
description: Use Minecraft color codes in chat
default: op
scopenet.command.scopenet.help: scopenet.command.scopenet.help:
description: Use /scopenet help description: Use /scopenet help
default: true default: true
+3 -96
View File
@@ -1,12 +1,12 @@
{ {
"name": "scopenet-launcher", "name": "scopenet-launcher",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scopenet-launcher", "name": "scopenet-launcher",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -15,29 +15,19 @@
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0", "@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.6.0", "@tauri-apps/plugin-opener": "^2.6.0"
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0", "@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^8.3.1" "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": { "node_modules/@fontsource-variable/inter": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
@@ -700,25 +690,6 @@
"@tauri-apps/api": "^2.12.0" "@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": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -726,13 +697,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -740,43 +704,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1216,13 +1136,6 @@
"@jridgewell/sourcemap-codec": "^1.6.0" "@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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1448,12 +1361,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "scopenet-launcher", "name": "scopenet-launcher",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -18,16 +18,13 @@
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0", "@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.6.0", "@tauri-apps/plugin-opener": "^2.6.0"
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0", "@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
+84 -30
View File
@@ -150,6 +150,16 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
Ok(options) 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 ---- // ---- accounts ----
#[tauri::command] #[tauri::command]
@@ -630,7 +640,7 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
.as_array() .as_array()
.into_iter() .into_iter()
.flatten() .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(); .collect();
*state.map_urls.lock().unwrap() = urls; *state.map_urls.lock().unwrap() = urls;
Ok(data) Ok(data)
@@ -643,15 +653,14 @@ pub struct MapResource {
etag: Option<String>, etag: Option<String>,
} }
/// Native transport for Vantage assets. Browser WebViews cannot fetch a /// Native transport for BlueMap settings and 2D tiles. Only paths under the
/// cross-origin Vantage live server, so the launcher uses its HTTP client. /// BlueMap address supplied by the panel can be fetched.
/// Only paths under a map URL supplied by the panel can be fetched.
#[tauri::command] #[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 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 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") if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin() || target.origin() != base.origin()
|| !target.path().starts_with(root.path()) || !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.password().is_some()
|| target.fragment().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 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) 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> { async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
use base64::Engine; use base64::Engine;
if target.as_str().len() > 2048 { 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 }); return Ok(MapResource { status, data: None, etag });
} }
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) { 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)?; let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 { 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 }) 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) 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] #[tauri::command]
pub async fn create_guild( pub async fn create_guild(
state: State<'_, AppState>, state: State<'_, AppState>,
+3
View File
@@ -129,6 +129,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
} else { } else {
current_options 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. // Do not rewrite pack files or options while another copy is using this directory.
if !already_running { if !already_running {
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?; sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
+10 -2
View File
@@ -52,6 +52,7 @@ pub fn run() {
commands::set_panel_url, commands::set_panel_url,
commands::save_settings, commands::save_settings,
commands::save_instance_options, commands::save_instance_options,
commands::set_instance_fov,
commands::login_panel, commands::login_panel,
commands::discord_sign_in_start, commands::discord_sign_in_start,
commands::discord_sign_in_poll, commands::discord_sign_in_poll,
@@ -96,8 +97,7 @@ pub fn run() {
commands::get_player_stats, commands::get_player_stats,
commands::get_leaderboard, commands::get_leaderboard,
commands::get_public_servers, commands::get_public_servers,
commands::fetch_vantage_resource, commands::fetch_bluemap_resource,
commands::fetch_livemap_resource,
commands::get_livemap_info, commands::get_livemap_info,
commands::get_my_level, commands::get_my_level,
commands::get_my_quests, commands::get_my_quests,
@@ -105,6 +105,14 @@ pub fn run() {
commands::get_my_achievements, commands::get_my_achievements,
commands::get_guilds, commands::get_guilds,
commands::get_my_guild, 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::create_guild,
commands::get_guild_claims, commands::get_guild_claims,
commands::claim_guild_chunk, 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`). /// Values baked in at build time by CI (see `.github/workflows/release.yml`).
pub mod build { pub mod build {
/// Default panel URL for this 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. /// `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. /// When set to "1", players can't change the panel URL.
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL"); pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
@@ -35,7 +41,7 @@ pub struct AppState {
pub accounts: RwLock<AccountsFile>, pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets, pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>, 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>>, pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable). /// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>, pub task: Mutex<Option<tokio::task::AbortHandle>>,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "SCOPENET Launcher", "productName": "SCOPENET Launcher",
"version": "0.4.0", "version": "0.5.0",
"identifier": "net.scopenet.launcher", "identifier": "net.scopenet.launcher",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+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>
+114 -469
View File
@@ -1,524 +1,169 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, RefreshCw, Server
} from '@lucide/svelte';
import { invoke } from '../lib/tauri'; 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 type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte'; import BlueMapTiles from './BlueMapTiles.svelte';
let { let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
instanceId, instanceId: string; myGuildId?: string | null; myGuildTag?: string;
myGuildId = null, onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
myGuildTag = '',
onclaim,
onunclaim
}: {
instanceId: string;
myGuildId?: string | null;
myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void;
onunclaim?: (claimId: number) => void;
} = $props(); } = $props();
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]); type Player = { x: number; z: number; dimension: string };
let servers = $state<GameServer[]>([]);
let serverId = $state<number | null>(null); let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld'); let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0); let centerX = $state(0), centerZ = $state(0);
let centerZ = $state(0); let player = $state<Player | null>(null);
let radius = $state(5); // 11x11 grid
let claims = $state<GuildClaim[]>([]); let claims = $state<GuildClaim[]>([]);
let loading = $state(false); let loading = $state(false), painting = $state(false);
let claiming = $state(false);
let claimLoad = 0; let claimLoad = 0;
let hoveredChunk = $state<{ x: number; z: number } | null>(null); let centeredServer = 0;
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);
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId)); const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const selectedServer = $derived(servers.find((s) => s.id === serverId)); const selectedServer = $derived(instanceServers.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 dimensions = [ const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe }, { id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass }, { id: 'minecraft:the_nether', label: 'Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'The End', icon: Compass } { id: 'minecraft:the_end', label: 'End', icon: Compass }
] as const; ] 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() { async function loadClaims() {
const request = ++claimLoad; const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; } if (!serverId || !instanceId) { claims = []; return; }
loading = true; loading = true;
claims = [];
try { try {
const next = await invoke<GuildClaim[]>('get_guild_claims', { const next = await invoke<GuildClaim[]>('get_guild_claims', { instanceId, serverId, dimension, centerX, centerZ });
instanceId,
serverId,
centerX,
centerZ,
dimension
});
if (request === claimLoad) claims = next; if (request === claimLoad) claims = next;
} catch (e: any) { } catch (e: any) {
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error'); if (request === claimLoad) toast(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
} finally { } finally { if (request === claimLoad) loading = false; }
if (request === claimLoad) loading = false;
}
} }
function getClaimAt(x: number, z: number): GuildClaim | undefined { async function paintChunks(chunks: { x: number; z: number }[], mode: 'claim' | 'unclaim') {
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension); 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 selectedClaim = $derived( const current = existing.get(`${x}:${z}`);
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null return mode === 'claim' ? !current : current?.guild_id === myGuildId;
); });
if (!wanted.length) return;
const myClaimsCount = $derived( painting = true;
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0 let done = 0;
);
async function handleClaim() {
if (!selectedChunk || !myGuildId || !serverId) return;
claiming = true;
try { try {
for (const chunk of wanted) {
try {
if (mode === 'claim') {
const claim = await invoke<GuildClaim>('claim_guild_chunk', { const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId, guildId: myGuildId, instanceId, serverId, dimension, chunkX: chunk.x, chunkZ: chunk.z
instanceId,
serverId,
dimension,
chunkX: selectedChunk.x,
chunkZ: selectedChunk.z
}); });
claims = [...claims, claim]; claims = [...claims, claim];
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
onclaim?.(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) { } catch (e: any) {
toast(e?.message ?? 'Failed to claim chunk', 'error'); toast(`${done} of ${wanted.length} chunks updated. ${typeof e === 'string' ? e : e?.message ?? 'Claim failed'}`, 'error');
} finally { break;
claiming = false;
} }
} }
if (done === wanted.length) toast(`${done} ${done === 1 ? 'chunk' : 'chunks'} ${mode === 'claim' ? 'claimed' : 'unclaimed'}`, 'ok');
} finally { painting = false; await loadClaims(); }
}
async function handleUnclaim() { function centerOnPlayer() {
if (!selectedClaim) return; if (!player) return;
claiming = true; dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
try { centerX = Math.floor(player.x / 16);
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id }); centerZ = Math.floor(player.z / 16);
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 pan(dx: number, dz: number) { onMount(() => {
centerX += dx; invoke<{ servers: GameServer[] }>('get_public_servers').then((r) => { servers = r.servers; })
centerZ += dz; .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);
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;
}); });
$effect(() => { $effect(() => {
const available = servers.filter(s => s.instance_id === instanceId); const available = instanceServers;
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null; 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> </script>
<div class="chunk-map-container glass"> <div class="claim-map glass">
<div class="toolbar"> <div class="toolbar">
<div class="tb-group"> <div class="group">
{#if instanceServers.length > 1} {#if instanceServers.length > 1}
<label class="server-pick" title="Game server"><Server size={14} /> <label class="server"><ServerIcon size={14} /><select bind:value={serverId} aria-label="Game server">
<select bind:value={serverId} aria-label="Game server"> {#each instanceServers as s}<option value={s.id}>{s.name}</option>{/each}
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each} </select></label>
</select> {:else if selectedServer}<span class="chip"><ServerIcon size={14} /> {selectedServer.name}</span>{/if}
</label> <div class="dimensions" role="tablist" aria-label="Dimension">
{: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">
{#each dimensions as dim} {#each dimensions as dim}
{@const DimIcon = dim.icon} {@const Icon = dim.icon}
<button <button role="tab" aria-selected={dimension === dim.id} class:active={dimension === dim.id}
role="tab" onclick={() => (dimension = dim.id)}><Icon size={14} /> {dim.label}</button>
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>
{/each} {/each}
</div> </div>
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
</div> </div>
<div class="group">
<div class="tb-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}
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span> <button class="ghost sm" onclick={centerOnPlayer} disabled={!player} title="Center on your current position"><LocateFixed size={15} /> Find me</button>
<div class="pad"> <button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
<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> </div>
</div> </div>
{#if !serverId} {#if selectedServer?.map_address}
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div> {#key `${serverId}:${selectedServer.map_address}`}
{/if} <BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{claims} {myGuildId} {player} onpaint={paintChunks}
<!-- Main Chunk Map Canvas / Grid --> oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
{#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; }}
/>
{/key} {/key}
{:else} {:else}
<div class="grid-wrapper"> <div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
<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} {/if}
</button> <div class="footer">
{/each} <span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
{/each} <span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
</div> {#if painting}<span>Updating claims…</span>{/if}
</div> {#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
{/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> </div>
</div> </div>
<style> <style>
.chunk-map-container { .claim-map { display: flex; flex-direction: column; gap: 0.8rem; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius); }
display: flex; .toolbar, .group, .footer, .server, .chip, .legend { display: flex; align-items: center; gap: 0.6rem; }
flex-direction: column; .toolbar, .footer { justify-content: space-between; flex-wrap: wrap; }
gap: 0.8rem; .group { flex-wrap: wrap; }
padding: 1rem; .server select { width: auto; min-width: 10rem; }
border-radius: var(--radius); .dimensions { display: flex; gap: 0.2rem; padding: 0.2rem; background: color-mix(in srgb, var(--bg) 70%, transparent); border-radius: 99px; }
background: color-mix(in srgb, var(--surface) 80%, transparent); .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; }
border: 1px solid var(--line); .dimensions button.active { background: color-mix(in srgb, var(--accent) 24%, transparent); color: var(--text); }
user-select: none; .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; }
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; } .footer { border-top: 1px solid var(--line); padding-top: 0.65rem; }
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .footer span { display: inline-flex; align-items: center; gap: 0.3rem; }
.server-pick { position: relative; display: inline-flex; align-items: center; } .legend i { width: 10px; height: 10px; border-radius: 2px; }
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; } .legend .mine { background: #4ade80; }.legend .other { background: #fb7185; }
.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);
}
</style> </style>
@@ -71,6 +71,15 @@
<div class="card col"> <div class="card col">
<strong>Vanilla game settings</strong> <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> <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> <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 () => { <button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
try { 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>
-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); await sleep(40);
switch (cmd) { switch (cmd) {
case 'get_public_servers': 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': 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 }] }; 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': case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] }; return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
+9 -1
View File
@@ -90,9 +90,17 @@ export async function init() {
theme(); theme();
pickSelected(); pickSelected();
subscribe(); subscribe();
app.ready = true;
invoke('show_window').catch(() => {}); invoke('show_window').catch(() => {});
if (app.panelUrl && !app.manifest) {
try {
await refresh();
} finally {
app.ready = true;
}
} else {
app.ready = true;
if (app.panelUrl) await refresh(); if (app.panelUrl) await refresh();
}
if (boot.settings.check_updates && boot.app.repo) { if (boot.settings.check_updates && boot.app.repo) {
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {}); invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
} }
+2 -1
View File
@@ -222,6 +222,7 @@ export interface Guild {
name: string; name: string;
tag: string; tag: string;
description: string; description: string;
motd?: string;
icon_url: string | null; icon_url: string | null;
banner_url: string | null; banner_url: string | null;
leader_uuid: string; leader_uuid: string;
@@ -234,7 +235,7 @@ export interface Guild {
export interface GuildMember { export interface GuildMember {
uuid: string; uuid: string;
name: string; name: string;
role: 'leader' | 'officer' | 'member'; role: string;
joined_at: string; joined_at: string;
online?: boolean; online?: boolean;
} }
+7
View File
@@ -12,6 +12,10 @@
{ usage: '/delhome <name>', does: 'Delete one of your homes.', perm: 'scopenet.command.delhome', aliases: ['rmhome'] }, { 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: '/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: '/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: '/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: '/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'] }, { usage: '/tpaccept', does: 'Accept a teleport request.', perm: 'scopenet.command.tpaccept', aliases: ['tpyes'] },
@@ -43,6 +47,7 @@
{ 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: '/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: '/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: '/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: [ { 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' }, { usage: '/guild bank', does: 'Show the bank balance and recent activity.', perm: 'scopenet.command.guild.bank' },
@@ -54,6 +59,8 @@
] }, ] },
{ title: 'Info & admin', blurb: 'Status and help.', cmds: [ { title: 'Info & admin', blurb: 'Status and help.', cmds: [
{ usage: '/playtime', does: 'Check your total and session playtime.', perm: 'scopenet.command.playtime', aliases: ['ontime'] }, { 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 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 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' }, { usage: '/scopenet reload', does: 'Reload the plugin config (admins).', perm: 'scopenet.command.scopenet.reload' },
+108 -2
View File
@@ -17,7 +17,16 @@
let allGuilds = $state<Guild[]>([]); let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]); let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]); 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 loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]); let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
@@ -58,9 +67,14 @@
]); ]);
members = mList; members = mList;
posts = pList; 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 { } else {
members = []; members = [];
posts = []; posts = [];
roles = [];
joinRequests = [];
} }
} catch (e: any) { } catch (e: any) {
toast(e?.message ?? 'Failed to load guild data', 'error'); toast(e?.message ?? 'Failed to load guild data', 'error');
@@ -85,6 +99,59 @@
} }
} }
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 () => { onMount(async () => {
loadInvites(); loadInvites();
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; } try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
@@ -242,6 +309,7 @@
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}> <button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
<Landmark size={15} /> Treasury <Landmark size={15} /> Treasury
</button> </button>
{#if canManageDetails}<button class:active={activeTab === 'settings'} onclick={() => (activeTab = 'settings')}>Guild settings</button>{/if}
</div> </div>
</div> </div>
@@ -270,6 +338,13 @@
<GuildWallet guild={myGuild} {members} servers={walletServers} instanceId={selectedInstId} /> <GuildWallet guild={myGuild} {members} servers={walletServers} instanceId={selectedInstId} />
{:else if activeTab === 'members'} {:else if activeTab === 'members'}
<div class="members-tab"> <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"> <div class="members-grid">
{#each members as m (m.uuid)} {#each members as m (m.uuid)}
<div <div
@@ -292,16 +367,42 @@
<span class="badge leader-badge"><Crown size={11} /> Leader</span> <span class="badge leader-badge"><Crown size={11} /> Leader</span>
{:else if m.role === 'officer'} {:else if m.role === 'officer'}
<span class="badge officer-badge"><UserCheck size={11} /> Officer</span> <span class="badge officer-badge"><UserCheck size={11} /> Officer</span>
{:else} {:else if m.role === 'member'}
<span class="badge member-role-badge">Member</span> <span class="badge member-role-badge">Member</span>
{:else}
<span class="badge member-role-badge">{m.role}</span>
{/if} {/if}
</div> </div>
<span class="tiny muted">Joined {new Date(m.joined_at).toLocaleDateString()}</span> <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>
</div> </div>
{/each} {/each}
</div> </div>
</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'} {:else if activeTab === 'feed'}
<div class="feed-tab"> <div class="feed-tab">
<!-- Post Announcement Form --> <!-- Post Announcement Form -->
@@ -407,6 +508,7 @@
{#if g.description} {#if g.description}
<p class="dir-desc">{g.description}</p> <p class="dir-desc">{g.description}</p>
{/if} {/if}
<button class="sm primary" onclick={() => requestJoin(g)}>Request to join</button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -436,6 +538,10 @@
<span class="field-label">Banner Image URL (Optional)</span> <span class="field-label">Banner Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/banner.png" bind:value={newGuildBanner} /> <input type="url" placeholder="https://example.com/banner.png" bind:value={newGuildBanner} />
</label> </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> </div>
{#snippet footer()} {#snippet footer()}
<button class="ghost" onclick={() => (createModalOpen = false)}>Cancel</button> <button class="ghost" onclick={() => (createModalOpen = false)}>Cancel</button>
+5 -6
View File
@@ -4,7 +4,6 @@
import ServerStatus from '../components/ServerStatus.svelte'; import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte'; import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte'; import Console from '../components/Console.svelte';
import LiveMapModal from '../components/LiveMapModal.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte'; import BlueMapModal from '../components/BlueMapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte'; import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format'; import { loaderName, shortLoaderVersion } from '../lib/format';
@@ -14,7 +13,7 @@
const b = $derived(app.manifest!.branding); const b = $derived(app.manifest!.branding);
// Lucide ships no brand logos, so each platform gets a recognisable generic icon. // 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 }; 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. // A game server linked to this instance with BlueMap configured.
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null); let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
let mapOpen = $state(false); let mapOpen = $state(false);
$effect(() => { $effect(() => {
@@ -23,8 +22,8 @@
mapOpen = false; mapOpen = false;
if (!id) return; if (!id) return;
let alive = true; let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean; map_address?: string }> }>('get_public_servers') 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 || s.live_map)) ?? null; }) .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
.catch(() => {}); .catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}); });
@@ -58,7 +57,7 @@
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button> <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" 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> <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} {#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div> </div>
</section> </section>
@@ -83,7 +82,7 @@
</aside> </aside>
{#if app.consoleOpen}<Console />{/if} {#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{:else 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> </div>
<style> <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> <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} {/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> <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> </div>
{/if} {/if}
</div> </div>
+12
View File
@@ -707,6 +707,18 @@ const MIGRATIONS: &[&str] = &[
); );
CREATE INDEX guild_invites_target ON guild_invites(target_uuid, status); 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> { pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+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 })); quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
} }
drop(conn); // give the connection back before the pool is used again 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?; 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!({ Ok(Json(json!({
@@ -87,7 +87,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
"friends": friends, "friends": friends,
"achievements": achievements, "achievements": achievements,
"quests": quests, "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. //! 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 //! 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::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
@@ -22,6 +23,8 @@ const API: &str = "https://discord.com/api/v10";
pub struct DiscordSettings { pub struct DiscordSettings {
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`. /// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
pub role_sync: String, 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. /// Shown to players in the launcher.
pub invite_url: String, pub invite_url: String,
/// Announcements go here. Empty turns them off. /// Announcements go here. Empty turns them off.
@@ -33,7 +36,7 @@ pub struct DiscordSettings {
impl Default for DiscordSettings { impl Default for DiscordSettings {
fn default() -> Self { 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 { fn view(s: &DiscordSettings) -> Value {
json!({ 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(), "webhook_set": !s.webhook_url.is_empty(),
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members, "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)] #[derive(Deserialize)]
pub struct SettingsInput { pub struct SettingsInput {
role_sync: String, role_sync: String,
#[serde(default)]
base_role_id: String,
invite_url: String, invite_url: String,
/// Omitted or empty keeps the saved webhook; `"-"` clears it. /// Omitted or empty keeps the saved webhook; `"-"` clears it.
#[serde(default)] #[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()) { if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
return Err(AppError::bad_request("unknown role sync mode")); 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 old = load(&state).await?;
let webhook = match i.webhook_url.trim() { let webhook = match i.webhook_url.trim() {
"" => old.webhook_url.clone(), "" => old.webhook_url.clone(),
@@ -100,6 +108,7 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
} }
let next = DiscordSettings { let next = DiscordSettings {
role_sync: i.role_sync, role_sync: i.role_sync,
base_role_id: i.base_role_id,
invite_url: invite, invite_url: invite,
webhook_url: webhook, webhook_url: webhook,
notify_achievements: i.notify_achievements, notify_achievements: i.notify_achievements,
@@ -197,6 +206,7 @@ pub struct SyncReport {
pub not_in_server: u32, pub not_in_server: u32,
pub groups_added: u32, pub groups_added: u32,
pub roles_added: u32, pub roles_added: u32,
pub roles_removed: u32,
pub failed: 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")); 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?; 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); 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?; 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) in linked { for (user_id, discord_id, uuid) in linked {
report.checked += 1; 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 = 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 { let resp = match resp {
@@ -235,6 +247,7 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
report.failed += 1; report.failed += 1;
break; break;
} }
if !resp.status().is_success() { report.failed += 1; continue; }
let Ok(member) = resp.json::<Value>().await else { let Ok(member) = resp.json::<Value>().await else {
report.failed += 1; report.failed += 1;
continue; 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 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 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 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 { 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?; 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; 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) 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") .header("Content-Length", "0")
.send() .send()
@@ -257,6 +276,16 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
} }
tokio::time::sleep(Duration::from_millis(250)).await; 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; tokio::time::sleep(Duration::from_millis(120)).await;
} }
Ok(report) Ok(report)
+139 -24
View File
@@ -437,13 +437,7 @@ pub async fn update_guild(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<UpdateGuildPayload>, Json(payload): Json<UpdateGuildPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") let is_officer_or_leader = guild_can(&state, &id, &auth.uuid, "manage").await?;
.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");
if !is_officer_or_leader { if !is_officer_or_leader {
return Err(AppError::forbidden("Only guild officers or leaders can update guild details")); return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
} }
@@ -472,6 +466,132 @@ pub struct AddMemberPayload {
pub username: String, 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. /// Add / Invite member to guild.
pub async fn add_guild_member( pub async fn add_guild_member(
auth: AuthUser, auth: AuthUser,
@@ -479,13 +599,7 @@ pub async fn add_guild_member(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<AddMemberPayload>, Json(payload): Json<AddMemberPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") if !guild_can(&state, &id, &auth.uuid, "invite").await? {
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
return Err(AppError::forbidden("Only guild leaders or officers can invite members")); return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
} }
@@ -536,7 +650,7 @@ pub async fn remove_guild_member(
.await?; .await?;
let is_self = auth.uuid == target_uuid; 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 { if !is_self && !is_leader_or_officer {
return Err(AppError::forbidden("Cannot kick member without officer privileges")); return Err(AppError::forbidden("Cannot kick member without officer privileges"));
@@ -573,8 +687,8 @@ pub async fn create_guild_post(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
if !in_guild { if !in_guild || !guild_can(&state, &id, &auth.uuid, "post").await? {
return Err(AppError::forbidden("Must be a guild member to post")); return Err(AppError::forbidden("Your guild role cannot post"));
} }
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
@@ -622,8 +736,8 @@ pub async fn claim_chunk(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
if role.is_none() { if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("You are not a member of this guild")); 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?; 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) .fetch_optional(&state.db)
.await?; .await?;
if role.is_none() { if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("You are not a member of this guild")); return Err(AppError::forbidden("Your guild role cannot unclaim land"));
} }
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); 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) .fetch_optional(&state.db)
.await?; .await?;
if role.is_none() { if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
return Err(AppError::forbidden("You are not a member of this guild")); 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?; sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
@@ -1033,6 +1147,7 @@ pub async fn server_claim_chunk(
let Some((guild_id, _role)) = member else { 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>")); 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 = 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); 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) .fetch_optional(&state.db)
.await?; .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")); 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 settings = load_settings(conn).await?;
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default(); 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 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 now = crate::db::now();
let mut assign = Vec::new(); let mut assign = Vec::new();
let mut level_assign = Vec::new();
for p in players.into_iter().take(2000) { for p in players.into_iter().take(2000) {
let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue }; 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(); 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) .bind(&now)
.execute(&mut *conn) .execute(&mut *conn)
.await?; .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() { if settings.luckperms_sync == "off" || mapped.is_empty() {
continue; 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 })); 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. //! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
use crate::auth::{AdminUser, AuthUser}; use crate::auth::{AdminUser, AuthUser};
use crate::error::AppResult; use crate::error::{AppError, AppResult};
use crate::state::AppState; use crate::state::AppState;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::Json; 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. _ => {} // 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(()) Ok(())
} }
@@ -278,6 +281,33 @@ pub struct RewardPayload {
pub reward_data: Option<Value>, 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. /// Admin create reward.
pub async fn admin_create_reward( pub async fn admin_create_reward(
_admin: AdminUser, _admin: AdminUser,
@@ -300,6 +330,7 @@ pub async fn admin_create_reward(
data_obj.insert("icon".into(), Value::String(ico)); data_obj.insert("icon".into(), Value::String(ico));
} }
data_obj.insert("title".into(), Value::String(reward_name.clone())); 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 data_str = serde_json::to_string(&data_obj).unwrap_or_default();
let id: i64 = sqlx::query_scalar( let id: i64 = sqlx::query_scalar(
@@ -317,6 +348,8 @@ pub async fn admin_create_reward(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true }))) 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("icon".into(), Value::String(ico));
} }
data_obj.insert("title".into(), Value::String(reward_name.clone())); 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 data_str = serde_json::to_string(&data_obj).unwrap_or_default();
sqlx::query( sqlx::query(
@@ -359,6 +393,8 @@ pub async fn admin_update_reward(
.execute(&state.db) .execute(&state.db)
.await?; .await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true }))) Ok(Json(serde_json::json!({ "id": id, "ok": true })))
} }
@@ -372,5 +408,6 @@ pub async fn admin_delete_reward(
.bind(id) .bind(id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
+8 -2
View File
@@ -146,8 +146,14 @@ pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &
} }
let mut conn = state.db.acquire().await?; 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 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?;
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") { let allowed = match role.as_deref() {
return Err(AppError::forbidden("Only guild leaders and officers can invite players")); 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: 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 target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
+5
View File
@@ -85,6 +85,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id)) .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}", 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", 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}/members/{uuid}", delete(guilds::remove_guild_member))
.route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post)) .route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post))
.route("/guilds/{id}/wallet", get(guilds::guild_wallet)) .route("/guilds/{id}/wallet", get(guilds::guild_wallet))
-2
View File
@@ -164,9 +164,7 @@ pub async fn adjust_player(
_ => return Err(AppError::bad_request("scope must be global or server")), _ => return Err(AppError::bad_request("scope must be global or server")),
}; };
// Level rewards (titles, badges) are earned, so they follow upward changes. // Level rewards (titles, badges) are earned, so they follow upward changes.
if change.new_xp > change.old_xp {
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?; crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
}
tx.commit().await?; tx.commit().await?;
let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect(); let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect();
+37
View File
@@ -0,0 +1,37 @@
mod common;
use common::*;
#[tokio::test]
async fn leader_approves_join_request_and_role_controls_claims() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for name in ["Leader", "Applicant"] {
let (status, body) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
assert_eq!(status, StatusCode::OK, "{body}");
}
let leader = t.login("Leader", "password123").await;
let applicant = t.login("Applicant", "password123").await;
let applicant_uuid = t.uuid("Applicant").await;
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await;
let server_id = server["server"]["id"].as_i64().unwrap();
let (status, guild) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
assert_eq!(status, StatusCode::OK, "{guild}");
let id = guild["id"].as_str().unwrap();
let path = format!("/api/v1/guilds/{id}");
assert_eq!(t.call("POST", &format!("{path}/requests"), Some(&applicant), Some(json!({"message":"Let me in"}))).await.0, StatusCode::OK);
assert_eq!(t.call("GET", &format!("{path}/requests"), Some(&applicant), None).await.0, StatusCode::FORBIDDEN);
let (_, requests) = t.call("GET", &format!("{path}/requests"), Some(&leader), None).await;
assert_eq!(requests[0]["message"], "Let me in");
assert_eq!(t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0, StatusCode::OK);
let (status, role) = t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
assert_eq!(status, StatusCode::OK, "{role}");
assert_eq!(t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0, StatusCode::OK);
let claim = json!({"server_id":server_id,"dimension":"minecraft:overworld","chunk_x":1,"chunk_z":1});
assert_eq!(t.call("POST", &format!("{path}/claim"), Some(&applicant), Some(claim)).await.0, StatusCode::FORBIDDEN);
let role_id = role["id"].as_i64().unwrap();
assert_eq!(t.call("DELETE", &format!("{path}/roles/{role_id}"), Some(&leader), None).await.0, StatusCode::OK);
let (_, members) = t.call("GET", &format!("{path}/members"), Some(&leader), None).await;
assert_eq!(members.as_array().unwrap().iter().find(|m| m["uuid"] == applicant_uuid).unwrap()["role"], "member");
}
+2 -97
View File
@@ -1,16 +1,12 @@
{ {
"name": "scopenet-panel-web", "name": "scopenet-panel-web",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scopenet-panel-web", "name": "scopenet-panel-web",
"version": "0.4.0", "version": "0.5.0",
"dependencies": {
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
},
"devDependencies": { "devDependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -20,20 +16,12 @@
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^8.3.1" "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": { "node_modules/@fontsource-variable/inter": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
@@ -456,25 +444,6 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.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": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -482,13 +451,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -496,43 +458,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -662,13 +587,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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -972,13 +890,6 @@
"@jridgewell/sourcemap-codec": "^1.6.0" "@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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1204,12 +1115,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+1 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "scopenet-panel-web", "name": "scopenet-panel-web",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -18,14 +18,9 @@
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^8.3.1" "vite": "^8.3.1"
},
"dependencies": {
"@thoughts-on-things/vantage-mc": "^0.15.1",
"three": "^0.180.0"
} }
} }
+30 -170
View File
@@ -1,192 +1,52 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onMount } from 'svelte';
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte'; import { MapPin, RefreshCw, Users } from '@lucide/svelte';
import { del, formatBytes, get, timeAgo } from '../lib/api'; import { get, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte'; import { toastError } from '../lib/toast.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import Modal from './Modal.svelte';
import type { LiveMapStatus } from '../lib/types'; import type { LiveMapStatus } from '../lib/types';
let { id, enabled }: { id: number; enabled: boolean } = $props(); let { id, enabled, address = '' }: { id: number; enabled: boolean; address?: string } = $props();
let status = $state<LiveMapStatus | null>(null); let status = $state<LiveMapStatus | null>(null);
let dim = $state('overworld');
let host = $state<HTMLDivElement>();
let viewer: any = null;
let loading = $state(false);
let viewError = $state('');
let resetOpen = $state(false);
let loadedKey = '';
async function refresh() { async function refresh() {
try { try { status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); }
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); catch (e) { toastError(e); }
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
} catch (e) {
toastError(e);
} }
} onMount(() => {
$effect(() => {
void id;
void enabled;
refresh(); refresh();
const t = setInterval(refresh, 10000); const timer = setInterval(refresh, 10_000);
return () => clearInterval(t); return () => clearInterval(timer);
}); });
function closeViewer() {
viewer?.dispose();
viewer = null;
loadedKey = '';
}
async function openViewer() {
if (!host || !status?.ready) return;
const key = `${id}:${dim}`;
if (loadedKey === key && viewer) return;
closeViewer();
loading = true;
viewError = '';
try {
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
import('@thoughts-on-things/vantage-mc/three'),
import('@thoughts-on-things/vantage-mc/core'),
]);
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
try {
await v.load({ world, view: 'top' });
} catch (e) {
v.dispose();
throw e;
}
viewer = v;
loadedKey = key;
} catch (e) {
viewError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever the dimension changes or the map becomes ready.
$effect(() => {
void dim;
if (status?.ready && host) openViewer();
});
onDestroy(closeViewer);
async function reset() {
try {
await del(`/api/admin/servers/${id}/livemap`);
closeViewer();
resetOpen = false;
toast('Live map data cleared — the server will re-upload its world');
refresh();
} catch (e) {
toastError(e);
}
}
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
</script> </script>
<section class="card live"> <section class="card relay">
<div class="section-title"> <div class="heading">
<h2><MapIcon size={17} /> Live Map</h2> <h2><MapPin size={17} /> Live player positions</h2>
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span> <span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button> <button class="ghost icon" aria-label="Refresh positions" onclick={refresh}><RefreshCw size={15} /></button>
</div> </div>
<p class="muted small">{address ? 'The launcher uses this server’s BlueMap for 2D terrain and these positions to center each player’s claim map.' : 'Add a BlueMap address to display 2D terrain in the launcher.'}</p>
{#if !enabled} {#if !enabled}
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p> <p class="muted small">Turn on live player positions in this server’s settings to let players center the map on themselves.</p>
{:else if status} {:else if status}
<div class="checks"> <p class="summary"><Users size={15} /> {status.players} online {status.players === 1 ? 'position' : 'positions'}
<div class="check" class:good={status.generator.installed}> {#if status.stats.last_players_at}<span class="muted small">· updated {timeAgo(status.stats.last_players_at)}</span>{/if}
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Cpu size={13} /> Vantage generator</span>
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
</div>
<div class="check" class:good={status.assets.configured}>
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Package size={13} /> Minecraft assets</span>
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
</div>
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Boxes size={13} /> World data</span>
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
</div>
<div class="check" class:good={status.players > 0}>
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Users size={13} /> Player positions</span>
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
</div>
</div>
{#if !status.generator.installed || !status.assets.configured}
<p class="hint-box small">
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
Install them as described in <code>docs/vantage-setup.md</code> — until then launchers show a “map is being set up” message.
</p> </p>
{/if} {#if status.roster.length}
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if} <div class="roster">
{#each status.roster as player (player.uuid)}
{#if status.ready} <span>{player.name} <small>{player.dimension.replace('minecraft:', '')} · {Math.floor(player.x)}, {Math.floor(player.z)}</small></span>
<div class="tabs">
{#each status.dimensions as d (d.slug)}
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
{/each} {/each}
<span class="grow"></span>
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
</div> </div>
<div class="stage">
<div class="viewer" bind:this={host}></div>
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
</div>
{:else if status.message}
<p class="muted small">{status.message}</p>
{/if} {/if}
{:else}
<div class="skeleton"></div>
{/if} {/if}
</section> </section>
<Modal bind:open={resetOpen} title="Reset map data?">
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
{/snippet}
</Modal>
<style> <style>
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; } .relay { display: flex; flex-direction: column; gap: 0.6rem; }
h2 { display: flex; align-items: center; gap: 8px; } .heading, .heading h2, .summary { display: flex; align-items: center; gap: 0.5rem; }
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; } .heading h2 { margin: 0 auto 0 0; font-size: 1rem; }
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; } .summary { margin: 0; }
.pill.off { background: var(--bg-2); color: var(--muted); } .roster { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; } .roster > span { display: flex; flex-direction: column; gap: 2px; padding: 0.5rem 0.7rem; border: 1px solid var(--line); border-radius: 8px; font-size: 0.83rem; }
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; } .roster small { color: var(--muted); }
.check.good { color: #6ee7b7; }
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
.check span :global(svg) { color: var(--muted); }
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
.err { color: #fda4af; }
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
.tab:disabled { opacity: 0.4; }
.grow { flex: 1; }
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
.viewer { position: absolute; inset: 0; }
.viewer :global(canvas) { display: block; }
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
</style> </style>
+2 -9
View File
@@ -21,14 +21,10 @@
<label class="field">Map address (BlueMap)<input type="url" bind:value={draft.map_address} placeholder="https://map.example.com" autocomplete="off" /><span class="tiny muted">Where this server's BlueMap is served. Players get a <strong>Live Map</strong> button in the launcher that shows it inside the app, exactly as in a browser. Install BlueMap on the server, then leave this empty to hide the button.</span></label> <label class="field">Map address (BlueMap)<input type="url" bind:value={draft.map_address} placeholder="https://map.example.com" autocomplete="off" /><span class="tiny muted">Where this server's BlueMap is served. Players get a <strong>Live Map</strong> button in the launcher that shows it inside the app, exactly as in a browser. Install BlueMap on the server, then leave this empty to hide the button.</span></label>
<Toggle <Toggle
bind:checked={draft.live_map_enabled} bind:checked={draft.live_map_enabled}
label="Live Map" label="Live player positions"
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server." help="The server plugin/mod sends live player positions to the panel so the launcher can center the BlueMap territory view on the player."
/> />
<label class="field">Shared economy group<input bind:value={draft.economy_group} maxlength="32" placeholder="Leave empty for a separate economy" /><span class="tiny muted">Servers with the same group name share player balances and guild banks, so one wallet follows players across your network. Existing per-server balances aren't merged.</span></label> <label class="field">Shared economy group<input bind:value={draft.economy_group} maxlength="32" placeholder="Leave empty for a separate economy" /><span class="tiny muted">Servers with the same group name share player balances and guild banks, so one wallet follows players across your network. Existing per-server balances aren't merged.</span></label>
<details class="adv">
<summary>Use an external Vantage map instead</summary>
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
</details>
<div class="field"> <div class="field">
<span class="lbl">Who can join</span> <span class="lbl">Who can join</span>
<div class="opts"> <div class="opts">
@@ -68,8 +64,5 @@
.opt.on :global(svg) { color: var(--accent-2); } .opt.on :global(svg) { color: var(--accent-2); }
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); } .gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); } .gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
.adv[open] summary { margin-bottom: 10px; }
.dot { width: 10px; height: 10px; border-radius: 50%; } .dot { width: 10px; height: 10px; border-radius: 50%; }
</style> </style>
+3 -7
View File
@@ -93,7 +93,6 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
export interface GameServer { export interface GameServer {
instance_id: string; instance_id: string;
map_url: string;
live_map_enabled: boolean; live_map_enabled: boolean;
map_address: string; map_address: string;
economy_group: string; economy_group: string;
@@ -149,19 +148,14 @@ export interface LiveMapStatus {
enabled: boolean; enabled: boolean;
ready: boolean; ready: boolean;
message: string; message: string;
base: string;
dimensions: { id: string; slug: string; label: string; available: boolean }[]; dimensions: { id: string; slug: string; label: string; available: boolean }[];
players: number; players: number;
generator: { installed: boolean; path: string | null };
assets: { configured: boolean; path: string | null };
world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] }; world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] };
stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null }; stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null };
running: string[];
last_error: string | null;
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
} }
export type ServerDraft = { instance_id?: string; name: string; map_url?: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export type ServerDraft = { instance_id?: string; name: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
export interface HostedDownload { export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux'; platform: 'windows' | 'mac' | 'linux';
@@ -216,6 +210,8 @@ export interface LevelReward {
reward_value: string; reward_value: string;
description: string; description: string;
icon?: string; icon?: string;
discord_role_id?: string;
luckperms_group?: string;
} }
export interface UserLevelInfo { export interface UserLevelInfo {
+14 -3
View File
@@ -8,6 +8,16 @@
let stats = $state<any>(null); let stats = $state<any>(null);
let instances = $state<any[]>([]); let instances = $state<any[]>([]);
let branding = $state<any>(null); let branding = $state<any>(null);
let showGettingStarted = $state(localStorage.getItem('scopenet_hide_getting_started') !== 'true');
function hideGettingStarted() {
showGettingStarted = false;
localStorage.setItem('scopenet_hide_getting_started', 'true');
}
function showGuide() {
showGettingStarted = true;
localStorage.removeItem('scopenet_hide_getting_started');
}
$effect(() => { $effect(() => {
get('/api/admin/stats').then((s) => (stats = s)); get('/api/admin/stats').then((s) => (stats = s));
@@ -38,6 +48,7 @@
{#if stats?.launcher_download_url} {#if stats?.launcher_download_url}
<a class="btn primary" href={stats.launcher_download_url} target="_blank" rel="noreferrer"><Download size={16} /> Launcher download</a> <a class="btn primary" href={stats.launcher_download_url} target="_blank" rel="noreferrer"><Download size={16} /> Launcher download</a>
{/if} {/if}
{#if !showGettingStarted}<button class="ghost" onclick={showGuide}>Show getting started</button>{/if}
</div> </div>
<div class="grid stats"> <div class="grid stats">
@@ -69,8 +80,8 @@
{/if} {/if}
</section> </section>
<section class="card"> {#if showGettingStarted}<section class="card">
<div class="section-title"><h2>Getting started</h2></div> <div class="section-title"><h2>Getting started</h2><button class="hint" onclick={hideGettingStarted} aria-label="Hide Getting started">Hide</button></div>
<div class="steps"> <div class="steps">
{#each steps as s} {#each steps as s}
<a class="step" class:done={s.done} href={s.href}> <a class="step" class:done={s.done} href={s.href}>
@@ -80,7 +91,7 @@
</a> </a>
{/each} {/each}
</div> </div>
</section> </section>{/if}
{#if stats?.live?.servers?.length} {#if stats?.live?.servers?.length}
<section class="card servers"> <section class="card servers">
+11 -3
View File
@@ -38,6 +38,8 @@
reward_value: x.reward_value ?? x.reward_name ?? '', reward_value: x.reward_value ?? x.reward_name ?? '',
description: x.description ?? '', description: x.description ?? '',
icon: x.icon ?? '🎁', icon: x.icon ?? '🎁',
discord_role_id: x.reward_data?.discord_role_id ?? '',
luckperms_group: x.reward_data?.luckperms_group ?? '',
})).sort((a, b) => a.level - b.level); })).sort((a, b) => a.level - b.level);
leaderboard = lb || []; leaderboard = lb || [];
const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression'); const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression');
@@ -77,10 +79,10 @@
saving = true; saving = true;
try { try {
if (draft.id) { if (draft.id) {
await put(`/api/admin/levels/rewards/${draft.id}`, draft); await put(`/api/admin/levels/rewards/${draft.id}`, { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
toast('Reward updated'); toast('Reward updated');
} else { } else {
await post('/api/admin/levels/rewards', draft); await post('/api/admin/levels/rewards', { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
toast('Reward created'); toast('Reward created');
} }
draftOpen = false; draftOpen = false;
@@ -209,7 +211,7 @@
<div class="grid-2"> <div class="grid-2">
<div class="field"> <div class="field">
<label for="r-lvl">Target Global Level</label> <label for="r-lvl">Target Global Level</label>
<input id="r-lvl" type="number" min="2" max="500" bind:value={draft.level} required /> <input id="r-lvl" type="number" min="1" max="500" bind:value={draft.level} required />
</div> </div>
<div class="field"> <div class="field">
<label for="r-type">Reward Type</label> <label for="r-type">Reward Type</label>
@@ -236,6 +238,12 @@
<label for="r-desc">Description</label> <label for="r-desc">Description</label>
<textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea> <textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea>
</div> </div>
{#if draft.reward_type === 'title'}
<div class="grid-2">
<div class="field"><label>Discord role ID<input bind:value={draft.discord_role_id} inputmode="numeric" placeholder="Optional role for this rank" /></label></div>
<div class="field"><label>LuckPerms group<input bind:value={draft.luckperms_group} placeholder="Optional in-game group" /></label></div>
</div>
{/if}
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button> <button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
+2 -2
View File
@@ -80,7 +80,7 @@
function openEdit() { function openEdit() {
if (!s) return; if (!s) return;
draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; draft = { instance_id: s.instance_id, name: s.name, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
editOpen = true; editOpen = true;
} }
async function saveEdit() { async function saveEdit() {
@@ -150,7 +150,7 @@
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div> <div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
</div> </div>
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} /> <LiveMapPanel id={s.id} enabled={s.live_map_enabled} address={s.map_address} />
<IntegrationsPanel id={s.id} /> <IntegrationsPanel id={s.id} />
+3 -2
View File
@@ -25,7 +25,7 @@
async function saveDisc() { async function saveDisc() {
savingDisc = true; savingDisc = true;
try { try {
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members }); disc = await put('/api/admin/discord', { role_sync: disc.role_sync, base_role_id: disc.base_role_id, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
discWebhook = ''; discWebhook = '';
toast('Discord community settings saved'); toast('Discord community settings saved');
loadDisc(); loadDisc();
@@ -203,7 +203,7 @@
{#if disc} {#if disc}
<section class="card col"> <section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Discord community</h2></div> <div class="section-title"><KeyRound size={18} /><h2>Discord community</h2></div>
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Sync only ever <em>adds</em>: it never removes a role or group. Map roles to groups under Players → Groups.</p> <p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Map group roles under Players → Groups and title roles under Leveling. The highest earned title role takes priority; obsolete title roles are removed.</p>
<label class="field">Role sync <label class="field">Role sync
<select bind:value={disc.role_sync}> <select bind:value={disc.role_sync}>
<option value="off">Off</option> <option value="off">Off</option>
@@ -212,6 +212,7 @@
<option value="both">Both directions</option> <option value="both">Both directions</option>
</select> </select>
</label> </label>
<label class="field">Base level Discord role ID<input bind:value={disc.base_role_id} inputmode="numeric" placeholder="Optional role for new players" /></label>
<label class="field">Invite link shown in the launcher<input bind:value={disc.invite_url} placeholder="https://discord.gg/yourcode" autocomplete="off" /></label> <label class="field">Invite link shown in the launcher<input bind:value={disc.invite_url} placeholder="https://discord.gg/yourcode" autocomplete="off" /></label>
<label class="field">Announcements webhook<input type="password" bind:value={discWebhook} placeholder={disc.webhook_set ? 'Saved — type to replace, or - to remove' : 'https://discord.com/api/webhooks/…'} autocomplete="off" /></label> <label class="field">Announcements webhook<input type="password" bind:value={discWebhook} placeholder={disc.webhook_set ? 'Saved — type to replace, or - to remove' : 'https://discord.com/api/webhooks/…'} autocomplete="off" /></label>
<Toggle bind:checked={disc.notify_achievements} label="Announce achievements" /> <Toggle bind:checked={disc.notify_achievements} label="Announce achievements" />
+9
View File
@@ -0,0 +1,9 @@
5e931abbb8b037c8de349afdd0b4165c23059e58672f7e0a11a27c4f70db6bed scopenet-client-fabric-1.20.1-0.5.0.jar
cb8690f842f0efb7a336c568a12e11c5bc9da2986ab68f337d4ca211c99cecb5 scopenet-fabric-1.20.1-0.5.0.jar
e1e6650e05f5a66655f88be8e892095e79ddf8cd5502affea9bd68033e33cc21 scopenet-fabric-1.21.1-0.5.0.jar
a54a1478c0740eb4aef20797037738355e3482ac1f4eaaa9b408d16e2303b7ed scopenet-fabric-26.3-0.5.0.jar
d9a22725d0b2554f31e010a6f596a73c023b339490ef982b5d53e8f866a8c447 scopenet-forge-1.20.1-0.5.0.jar
49098a90919fdea02f4eec26e332cd6a51fc7bb398ba1d55f87528f52610e250 scopenet-forge-1.21.1-0.5.0.jar
31e5076947f33ad1ec0037d92736b28eb8a47e4be84a6bacbf1dfd11308f579a scopenet-forge-26.3-0.5.0.jar
1f4428de07b3061862c5dac3c7214ae87f9525385533a8c244ab67ec31ee76da scopenet-paper-0.5.0.jar
3ade714b0869b9fbb0e6175280b69d5bb6531543ce8ce48c7f231f170081dd5f SCOPENET Launcher_0.5.0_x64-setup.exe
Binary file not shown.