Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0bf39071a | ||
|
|
deabea60ca | ||
|
|
7eea617225 | ||
|
|
aae0a5a5ff | ||
|
|
cd985a1a7b | ||
|
|
2cc009d66f | ||
|
|
f9b0dcedff | ||
|
|
6fd78f32ea | ||
|
|
6c58042a32 | ||
|
|
8e1f04c867 | ||
|
|
74ca07fe93 | ||
|
|
280f9eaf1b | ||
|
|
19d111eeff | ||
|
|
c859628e93 | ||
|
|
0dc948caa4 | ||
|
|
209ade0119 | ||
|
|
c37cde5cc8 | ||
|
|
f428bd210f | ||
|
|
5346b1f560 | ||
|
|
d98cc61ee6 | ||
|
|
ead4a706c7 | ||
|
|
30b1d92437 | ||
|
|
dbe02dc9bf | ||
|
|
9102e2fc52 | ||
|
|
7a1f3431ae | ||
|
|
6a2944a88e | ||
|
|
434ed5338a | ||
|
|
c0daa0507a | ||
|
|
228727a395 | ||
|
|
2817ad2ac5 | ||
|
|
ad7285efac | ||
|
|
e160ad8da3 | ||
|
|
878827625a | ||
|
|
eba2ccfdab | ||
|
|
1954448219 |
No files matched your search
@@ -11,6 +11,4 @@ PUBLIC_URL=
|
||||
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
|
||||
SCOPENET_TRUSTED_PROXIES=
|
||||
|
||||
# Live Map: optional static Vantage Linux release to bundle, and memory for the panel.
|
||||
VANTAGE_URL=
|
||||
PANEL_MEMORY=256M
|
||||
Generated
+4
-4
@@ -3999,7 +3999,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-core"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4021,7 +4021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-launcher"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4047,7 +4047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-panel"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -4083,7 +4083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "scopenet-shared"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"md-5",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/scopeddlol/SCOPENET-MC"
|
||||
|
||||
|
||||
+2
-17
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src/panel/web
|
||||
COPY panel/web/package.json panel/web/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
# The map viewer is shared with the launcher (imported as @scopenet/map).
|
||||
COPY shared/ /src/shared/
|
||||
COPY panel/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
@@ -36,20 +38,6 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry,sharin
|
||||
# Data dir owned by the non-root runtime user.
|
||||
RUN mkdir -p /out/data && chown 65532:65532 /out/data
|
||||
|
||||
# ---- 2b. Vantage generator (optional) ----------------------------------------
|
||||
# The live map renders with Vantage. Point VANTAGE_URL at a static Linux
|
||||
# release archive (tar.gz or zip) for the target arch to bake it into the image;
|
||||
# leave it empty to mount your own binary at /opt/vantage/vantage instead.
|
||||
FROM --platform=$BUILDPLATFORM alpine:3 AS vantage
|
||||
ARG VANTAGE_URL=""
|
||||
RUN apk add --no-cache curl tar unzip && mkdir -p /out/vantage /out/assets \
|
||||
&& if [ -n "$VANTAGE_URL" ]; then \
|
||||
curl -fsSL "$VANTAGE_URL" -o /tmp/vantage.pkg \
|
||||
&& (tar -xzf /tmp/vantage.pkg -C /out/vantage 2>/dev/null || unzip -q /tmp/vantage.pkg -d /out/vantage) \
|
||||
&& find /out/vantage -type f -name vantage -exec chmod 755 {} + \
|
||||
&& (f=$(find /out/vantage -type f -name vantage | head -n1) && [ -n "$f" ] && [ "$f" != /out/vantage/vantage ] && mv "$f" /out/vantage/vantage || true); \
|
||||
fi
|
||||
|
||||
# ---- 3. Runtime --------------------------------------------------------------
|
||||
FROM scratch
|
||||
LABEL org.opencontainers.image.title="SCOPENET Panel" \
|
||||
@@ -58,12 +46,9 @@ LABEL org.opencontainers.image.title="SCOPENET Panel" \
|
||||
COPY --from=build /scopenet-panel /scopenet-panel
|
||||
COPY --from=web /src/panel/web/dist /web
|
||||
COPY --from=build --chown=65532:65532 /out/data /data
|
||||
COPY --from=vantage /out/vantage /opt/vantage
|
||||
ENV SCOPENET_BIND=0.0.0.0:8080 \
|
||||
SCOPENET_DATA_DIR=/data \
|
||||
SCOPENET_WEB_DIR=/web \
|
||||
SCOPENET_VANTAGE_BIN=/opt/vantage/vantage \
|
||||
SCOPENET_VANTAGE_ASSETS=/data/livemap/assets \
|
||||
RUST_LOG=info
|
||||
USER 65532:65532
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -99,6 +99,9 @@ docker-compose.yml Panel deployment
|
||||
|
||||
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
||||
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
|
||||
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
|
||||
- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players
|
||||
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
|
||||
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
||||
- [Development](docs/development.md) — running everything locally, tests
|
||||
- [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups
|
||||
|
||||
@@ -10,9 +10,6 @@ services:
|
||||
image: scopenet-panel:local
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Optional: URL of a static Vantage Linux release to bundle.
|
||||
VANTAGE_URL: ${VANTAGE_URL:-}
|
||||
container_name: scopenet-panel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -29,10 +26,6 @@ services:
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
|
||||
# Live Map: Vantage generator + Minecraft client assets (see docs/live-map.md).
|
||||
SCOPENET_VANTAGE_BIN: ${SCOPENET_VANTAGE_BIN:-/opt/vantage/vantage}
|
||||
SCOPENET_VANTAGE_ASSETS: ${SCOPENET_VANTAGE_ASSETS:-/data/livemap/assets}
|
||||
SCOPENET_VANTAGE_ARGS: ${SCOPENET_VANTAGE_ARGS:-}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
volumes:
|
||||
- panel-data:/data
|
||||
@@ -40,7 +33,6 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
# Raise this if you enable Live Map: Vantage bakes tiles in memory.
|
||||
memory: ${PANEL_MEMORY:-256M}
|
||||
|
||||
volumes:
|
||||
|
||||
+2
-5
@@ -23,11 +23,8 @@ The panel listens on `PANEL_PORT` (default `8080`). Docker checks
|
||||
`docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
|
||||
blank on first start, the generated password appears in the panel logs.
|
||||
|
||||
To show a Vantage world map in the launcher, deploy Vantage next to each game
|
||||
server and configure its manifest URL in the panel. See
|
||||
[Live map setup](live-map.md). Vantage needs access to the game world's saved
|
||||
files, so it runs alongside the game server rather than inside the panel
|
||||
container.
|
||||
Servers draw their own world map and send it to the panel; nothing extra to
|
||||
install or host. See [The SCOPENET Map](map.md).
|
||||
|
||||
The named `panel-data` volume holds the database, uploads, hosted launcher
|
||||
downloads, and the generated JWT signing key. Preserve that volume when
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Discord messages, live embeds and emails
|
||||
|
||||
## Announcements (Admin panel → Discord → Announcements)
|
||||
|
||||
Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it:
|
||||
|
||||
- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields.
|
||||
- The sender's name and picture.
|
||||
- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot.
|
||||
- Player names are cleaned so they can't ping anyone or break the formatting.
|
||||
|
||||
Switch each message on or off, and press **Save & send a sample** to see it in your channel.
|
||||
|
||||
## Live embeds (Discord → Live embeds)
|
||||
|
||||
Four boards that the panel posts once and then **edits in place**, so the channel never fills up:
|
||||
|
||||
| Embed | Shows | Rows are set by |
|
||||
|---|---|---|
|
||||
| **Server status** | Every server: online, players, TPS, version | the line template |
|
||||
| **Active guilds** | Top guilds with level, members, land and leader | rows shown |
|
||||
| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown |
|
||||
| **Richest players** | Top balances of one server economy | economy of, rows shown |
|
||||
|
||||
Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it.
|
||||
|
||||
## Emails (Admin panel → Emails)
|
||||
|
||||
Set up the sender and Resend key under **Settings → Connections** first. Then:
|
||||
|
||||
1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`.
|
||||
2. Check the live **preview**, and **send a test** to yourself.
|
||||
3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons.
|
||||
|
||||
Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account.
|
||||
|
||||
## Live map on the landing page
|
||||
|
||||
In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again.
|
||||
@@ -1,73 +0,0 @@
|
||||
# Live Map (central Vantage)
|
||||
|
||||
> **Setting it up?** Follow the step-by-step guide: [vantage-setup.md](vantage-setup.md). This page is the overview.
|
||||
|
||||
The panel hosts a [Vantage](https://github.com/thoughts-on-things/vantage-mc) 3D map
|
||||
**for every game server**, so nobody has to run Vantage next to Minecraft.
|
||||
|
||||
```
|
||||
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
|
||||
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
|
||||
```
|
||||
|
||||
1. **Collect** – the SCOPENET Paper plugin / Fabric / Forge mod reads the world's
|
||||
region files (only chunks newer than what the panel already has) and sends them to
|
||||
the panel over HTTPS. Player positions stream over a WebSocket (HTTPS fallback).
|
||||
2. **Store & render** – the panel keeps a region-file mirror per server under
|
||||
`<data>/livemap/<server id>/world` and runs a `vantage server` sidecar over it on
|
||||
demand (stopped again after 15 idle minutes). Sidecars listen on loopback only.
|
||||
3. **Serve** – `GET /api/livemap/<server>/<dimension>/v1/worlds/default/…` proxies the
|
||||
sidecar to signed-in launcher/admin sessions. Player markers come straight from
|
||||
memory. One map per server and dimension (`overworld`, `the_nether`, `the_end`).
|
||||
4. **View** – the launcher checks `GET /api/v1/servers/<id>/livemap`; when Live Map is
|
||||
on for a server linked to the instance, the instance page shows a **Live Map**
|
||||
button that opens the map inside the launcher (and the Guilds territory view uses
|
||||
it too). Admins see the same map on *Servers → your server*.
|
||||
|
||||
Chunk data only reaches the panel when the game server has written it, so the map
|
||||
follows autosaves (and `/save-all`) rather than every block change.
|
||||
|
||||
## Turn it on
|
||||
|
||||
1. **Servers → your server → Settings → Live Map** (on by default for new servers).
|
||||
2. Update the plugin/mod. `livemap.enabled: true` (Paper `config.yml`) or
|
||||
`livemap.enabled=true` (`config/scopenet.properties`) is the default; set it to
|
||||
`false` to keep a server off the map locally.
|
||||
3. The server page shows a checklist: generator, assets, world data, player positions.
|
||||
|
||||
## Panel requirements
|
||||
|
||||
Rendering needs two things on the machine running the panel:
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `SCOPENET_VANTAGE_BIN` | The `vantage` executable (default: `vantage` on `PATH`, then `/opt/vantage/vantage`). |
|
||||
| `SCOPENET_VANTAGE_ASSETS` | Minecraft client assets directory passed as `--assets` (default `<data>/livemap/assets`). |
|
||||
| `SCOPENET_VANTAGE_ARGS` | Optional extra `vantage server` flags, e.g. `--radius 2048 --memory 1024`. |
|
||||
|
||||
With Docker, either mount your binary at `/opt/vantage/vantage` and the assets at
|
||||
`/data/livemap/assets`, or bake a static Linux release in with
|
||||
`VANTAGE_URL=<release archive> docker compose build`. Until both exist the panel still
|
||||
stores what servers send and launchers show "the map is being prepared".
|
||||
Give the container more than the default 256 MB if Live Map is on (`PANEL_MEMORY`).
|
||||
|
||||
## Security
|
||||
|
||||
* Game servers authenticate with their existing `sn_…` token; the endpoints refuse
|
||||
servers whose Live Map switch is off.
|
||||
* Viewers must be signed in; the launcher attaches its session token natively, so it
|
||||
never reaches page scripts. Anyone with an account can view a server's map — use
|
||||
the server's access rules and leave Live Map off for private worlds.
|
||||
* The sidecar gets a random per-process bearer token and never leaves loopback.
|
||||
|
||||
## Ingest API (for reference)
|
||||
|
||||
All under `/api/server/v1/livemap/` with `Authorization: Bearer <server token>`:
|
||||
`GET config`, `GET manifest?dim=`, `POST chunks?dim=` (binary records of
|
||||
`i32 x, i32 z, u32 timestamp, u8 compression, u32 length, bytes`), `POST level`
|
||||
(`level.dat`), `POST players`, `GET ws` (WebSocket: JSON `players` frames).
|
||||
|
||||
## External Vantage (advanced)
|
||||
|
||||
Under *Use an external Vantage map instead* you can still point a server at a
|
||||
`manifest.json` / `world.json` you host yourself. It is only used when Live Map is off.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# The SCOPENET Map
|
||||
|
||||
Every server draws its own world map. There is nothing to install, host or configure besides the plugin or mod you already run: the game server renders its world to small tiles in the background, sends only what changed to the panel, and the launcher and admin panel show them in a fast native map.
|
||||
|
||||
What's on it:
|
||||
|
||||
- **Terrain** in the look of a Minecraft map, coloured from the real block textures (modded blocks get a matching colour). The Nether is drawn below its ceiling, so you see caves and lava lakes, not bedrock.
|
||||
- **Guild land** as coloured outlines with the guild's icon and name, holes and all.
|
||||
- **Spawn, warps, guild homes, markets and shop points** as pins you can click.
|
||||
- **Players online**, with their head and facing direction. Click one to open the player panel (profile, teleport request, message in game, invite to your guild).
|
||||
- **Dimensions**: Overworld, Nether and End tabs appear as soon as the server has drawn them.
|
||||
|
||||
## Turn it on
|
||||
|
||||
It is on by default for every server. To change it:
|
||||
|
||||
1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch.
|
||||
2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read.
|
||||
|
||||
The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority.
|
||||
|
||||
## In the launcher
|
||||
|
||||
Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles.
|
||||
|
||||
## In the admin panel
|
||||
|
||||
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
|
||||
|
||||
## On your public landing page
|
||||
|
||||
Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page).
|
||||
|
||||
## How it works
|
||||
|
||||
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
|
||||
- Tiles are palette PNGs, so a typical tile is a few kilobytes. Only tiles that changed are uploaded, and a region is redrawn at most every 90 seconds while players are building.
|
||||
- The panel stores tiles under its data folder (`map/<server id>/…`) and serves them to signed-in players only, with a short-lived key on every address and ETag caching.
|
||||
- Player positions go up about every two seconds; claims and pins whenever they change.
|
||||
- A local cache lives next to the plugin (`plugins/SCOPENET/map`) or mod (`config/scopenet/map`) so a restart doesn't redraw anything.
|
||||
|
||||
Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the panel. Servers using region files compressed with LZ4 (an opt-in setting in recent Minecraft versions) are not drawn; leave `region-file-compression` on its default.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing.
|
||||
- **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18).
|
||||
- **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Ranks, guild roles, chat and warps
|
||||
|
||||
## Level ranks
|
||||
|
||||
In the panel's **Leveling** page, create a **Player Title / Prefix** reward at each global level milestone. A title can name a LuckPerms group and a Discord role ID. The highest title the player has earned is shown in Paper chat and, when LuckPerms is installed, its configured group is assigned automatically. SCOPENET only changes LuckPerms groups explicitly named on title rewards. Existing panel group to LuckPerms mappings still work independently.
|
||||
|
||||
In **Settings → Discord community**, enable role sync and optionally set a base role ID. Linked players receive the Discord role for their highest earned title; players without a mapped title receive the base role. When a higher title replaces a lower one, the old managed title role is removed. Players without a linked Discord account cannot receive a Discord role; in-game chat shows the base `Novice` title until they earn a title.
|
||||
|
||||
Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname: message`. LuckPerms prefixes and suffixes decorate the name. Messages support `**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`, backtick code, and Bukkit `&` formatting. `&#RRGGBB` colors and `&` codes require `scopenet.chat.color`, which is granted to operators by default. Players set or clear a persistent nickname with `/nick <name|off>`; chat also respects display names set by other plugins.
|
||||
|
||||
## Guilds
|
||||
|
||||
Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`.
|
||||
|
||||
The in-app territory view draws on the server's SCOPENET Map tiles (see [map.md](map.md)). It centers on the player's last position while they are in game. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
|
||||
|
||||
## Warps and item sharing on Paper
|
||||
|
||||
Players use `/reqwarp <name>` at the requested location. An operator or player with `scopenet.command.warp.manage` uses `/warprequests`, then `/warpapprove <name>` or `/warpdeny <name>`. Admins can create and remove warps directly with `/setwarp <name>` and `/delwarp <name>`. Pending requests are stored in `plugins/SCOPENET/warp_requests.yml`; approved warps are stored in `warps.yml`.
|
||||
|
||||
`/hand` sends the held item as a clickable chat entry. Other players can inspect a read-only snapshot for five minutes. The launcher **Command Guide** lists these commands and their permission nodes.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Rewards, limits and permission nodes
|
||||
|
||||
## Rewards beyond XP
|
||||
|
||||
Quests, achievements and rank milestones (level rewards) can hand out more than XP and a title. In the admin panel, open any quest, achievement or milestone and use **Extra rewards**:
|
||||
|
||||
| Reward | What happens | Where it happens |
|
||||
|---|---|---|
|
||||
| **Money** | Credits the balance, once per economy group (or one chosen server) | Panel, instantly |
|
||||
| **Claim chunks** | Raises the land limit of the player's guild while they are a member | Panel, instantly |
|
||||
| **Badge** | Shows a badge on their profile | Panel, instantly |
|
||||
| **Item** | Puts the item in their inventory (anything that doesn't fit drops at their feet). Vanilla ids such as `diamond` or `modid:item` | Game server, when they are online |
|
||||
| **Permission** | Grants a permission node, permanently or for a number of days | Game server (LuckPerms by default) |
|
||||
| **Group** | Adds them to a permission group | Game server (LuckPerms by default) |
|
||||
| **Message** | Sends a chat message | Game server |
|
||||
| **Command** | Runs a console command with `{player}` and `{uuid}` filled in | Game server, only if the server allows it |
|
||||
|
||||
Players see short summaries ("$250.00", "Diamond x3", "+4 claim chunks") next to each quest and achievement in the launcher. Commands are shown only as "A special reward".
|
||||
|
||||
Items, permissions, groups and messages are queued in the panel and handed to the game server once the player is online there. Anything that fails (an unknown item, LuckPerms missing) is retried a few times and then kept in the delivery log with the reason. A reward earned on one server can be delivered by whichever server the player is on, unless you pin it to a server.
|
||||
|
||||
### Server settings
|
||||
|
||||
Paper `config.yml` / Fabric `scopenet.properties`:
|
||||
|
||||
```yaml
|
||||
rewards:
|
||||
enabled: true
|
||||
allow-commands: false # let the panel run its own console commands. Leave off unless every panel admin is trusted.
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
```
|
||||
|
||||
Using another permissions plugin? Change the command templates. Placeholders: `{player}`, `{uuid}`, `{node}`, `{value}`, `{duration}`, `{group}`.
|
||||
|
||||
## What you can configure
|
||||
|
||||
### Per server (plugin/mod config)
|
||||
|
||||
| Setting | Meaning |
|
||||
|---|---|
|
||||
| `essentials.max-homes` | Homes every player can set |
|
||||
| `essentials.home-name-max-length` | Longest home name |
|
||||
| `essentials.max-warps` | Most warps the server may have (0 = no limit) |
|
||||
| `essentials.tpa-timeout-seconds` | How long a `/tpa` request stays open |
|
||||
| `essentials.rtp-radius`, `rtp-min-radius`, `rtp-attempts` | Where `/rtp` sends players, and how hard it tries |
|
||||
| `essentials.cooldowns.<command>` | Seconds between uses of `spawn`, `home`, `back`, `tpa`, `rtp`, `warp` |
|
||||
| `map.enabled`, `map.show-homes` | The SCOPENET Map and whether homes appear on it |
|
||||
| `economy.currency-symbol` | The symbol shown next to money |
|
||||
| `rewards.*` | See above |
|
||||
| Feature switches | `leveling`, `quests`, `achievements`, `guilds`, `social`, `essentials`, `economy` |
|
||||
|
||||
### Network-wide (admin panel → Progression → Economy & guild rules)
|
||||
|
||||
Starting balance, chunks a new guild may claim, extra claims per member and per guild level, guild member limit, and market listings per player. These apply on every server and take effect immediately.
|
||||
|
||||
## Permission nodes that change limits
|
||||
|
||||
Give these with LuckPerms (or any permissions plugin) to ranks:
|
||||
|
||||
| Node | Effect |
|
||||
|---|---|
|
||||
| `scopenet.homes.<number>` | Homes limit for the player, e.g. `scopenet.homes.10`. The highest one a player has wins and never lowers the base `max-homes` |
|
||||
| `scopenet.homes.unlimited` | No home limit |
|
||||
| `scopenet.cooldown.bypass` | Skip every command cooldown |
|
||||
|
||||
Operators and anyone with `*` get the top tier on Paper. On Fabric these nodes are off for everyone until a permissions system (such as the LuckPerms mod) grants them; operators still skip cooldowns.
|
||||
|
||||
Claim chunks are a guild limit, so they are changed with the network rules above or with **Claim chunks** rewards, not with a permission node.
|
||||
@@ -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.
|
||||
@@ -21,15 +21,31 @@ social.chat_prefixes=true
|
||||
essentials.enabled=true
|
||||
economy.enabled=true
|
||||
|
||||
# Send world chunks and player positions to the panel's Live Map
|
||||
# (also needs Live Map switched on for this server in the panel).
|
||||
livemap.enabled=true
|
||||
# Draw this world on the SCOPENET Map: tiles, player positions, guild land and pins go to the panel
|
||||
# (also needs the map switched on for this server in the panel, which it is by default).
|
||||
map.enabled=true
|
||||
|
||||
# --- Essentials (1.20.1) -----------------------------------------------------
|
||||
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
|
||||
# scopenet.homes.25 gives 25 (highest wins), scopenet.homes.unlimited removes the limit.
|
||||
essentials.max_homes=5
|
||||
essentials.rtp_radius=2500
|
||||
essentials.home_name_max_length=32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
essentials.max_warps=0
|
||||
essentials.tpa_timeout_seconds=60
|
||||
# /rtp picks a random safe spot between rtp_min_radius and rtp_radius blocks from 0,0.
|
||||
essentials.rtp_radius=2500
|
||||
essentials.rtp_min_radius=0
|
||||
essentials.rtp_attempts=15
|
||||
# Seconds between uses (0 = none). Starts when the command actually teleports.
|
||||
# Players with scopenet.cooldown.bypass skip all of these.
|
||||
essentials.cooldown.spawn=0
|
||||
essentials.cooldown.home=0
|
||||
essentials.cooldown.back=0
|
||||
essentials.cooldown.tpa=0
|
||||
essentials.cooldown.rtp=0
|
||||
essentials.cooldown.warp=0
|
||||
|
||||
# --- Economy (1.20.1) --------------------------------------------------------
|
||||
# Shown next to amounts.
|
||||
@@ -52,3 +68,16 @@ placeholders.enabled=true
|
||||
# --- Client mod link (1.20.1) ------------------------------------------------
|
||||
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
|
||||
clientlink.enabled=true
|
||||
|
||||
# --- Map pins ----------------------------------------------------------------
|
||||
# Show every player's homes as pins on the map. Off by default: homes are private.
|
||||
map.show_homes=false
|
||||
|
||||
# --- Rewards -----------------------------------------------------------------
|
||||
# Quests, achievements and rank milestones can give items, permissions, groups, messages... (set in the admin panel).
|
||||
rewards.enabled=true
|
||||
# Let the panel run its own console commands as rewards. Leave off unless you trust every panel admin.
|
||||
rewards.allow_commands=false
|
||||
rewards.permission_command=lp user {player} permission set {node} true
|
||||
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
|
||||
rewards.group_command=lp user {player} parent add {group}
|
||||
@@ -58,17 +58,38 @@ social:
|
||||
essentials:
|
||||
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||
enabled: true
|
||||
# Homes per player.
|
||||
# Homes every player can set. Raise it for a rank with a permission node:
|
||||
# scopenet.homes.10 gives 10 homes, scopenet.homes.25 gives 25 (highest wins),
|
||||
# scopenet.homes.unlimited removes the limit. Operators and "*" get the top tier.
|
||||
max-homes: 5
|
||||
# /rtp picks a random safe spot within this many blocks of 0,0.
|
||||
# Longest allowed home name.
|
||||
home-name-max-length: 32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
max-warps: 0
|
||||
# How long a /tpa request stays open.
|
||||
tpa-timeout-seconds: 60
|
||||
# /rtp picks a random safe spot between rtp-min-radius and rtp-radius blocks from 0,0.
|
||||
rtp-radius: 2500
|
||||
rtp-min-radius: 0
|
||||
# Random spots tried before /rtp gives up.
|
||||
rtp-attempts: 15
|
||||
# Seconds between uses (0 = none). Starts when the command actually teleports.
|
||||
# Players with scopenet.cooldown.bypass skip all of these.
|
||||
cooldowns:
|
||||
spawn: 0
|
||||
home: 0
|
||||
back: 0
|
||||
tpa: 0
|
||||
rtp: 0
|
||||
warp: 0
|
||||
|
||||
livemap:
|
||||
# Send world chunks and player positions to the panel's central 3D Live Map.
|
||||
# Nothing runs on this server; the panel renders it. Needs "Live Map" switched on
|
||||
# for this server in the panel, and Vantage installed on the panel.
|
||||
# Setup guide: docs/vantage-setup.md
|
||||
map:
|
||||
# The SCOPENET Map: this server draws its world to map tiles in the background and
|
||||
# sends only what changed, with players, guild land and pins, to the panel.
|
||||
# It also needs the map switched on for this server in the panel (on by default).
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private.
|
||||
show-homes: false
|
||||
|
||||
economy:
|
||||
# /balance /pay /baltop /shop /sell /market /orders /trade /transactions
|
||||
@@ -114,6 +135,23 @@ integrations:
|
||||
enabled: true
|
||||
report-interval-seconds: 30
|
||||
|
||||
# ==============================================================================
|
||||
# Rewards
|
||||
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
|
||||
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
|
||||
# to this server when the player is online. Permissions and groups use console commands, so any permissions
|
||||
# plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
|
||||
# ==============================================================================
|
||||
rewards:
|
||||
enabled: true
|
||||
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the
|
||||
# panel could otherwise run commands on this server. Only switch it on if you trust every panel admin.
|
||||
allow-commands: false
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-deny-command: "lp user {player} permission set {node} false"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
|
||||
# ==============================================================================
|
||||
# Client mod link
|
||||
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
|
||||
|
||||
@@ -36,7 +36,9 @@ public record ScopenetPlayer(
|
||||
QuestProgress weeklyQuests,
|
||||
String joined,
|
||||
String rankGroup,
|
||||
String rankPrefix) {
|
||||
String rankDisplay,
|
||||
String rankPrefix,
|
||||
String rankSuffix) {
|
||||
|
||||
public boolean inGuild() { return guild != null; }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
allprojects {
|
||||
group = 'net.scopenet'
|
||||
version = providers.gradleProperty('releaseVersion').getOrElse('0.4.0')
|
||||
version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
|
||||
repositories { mavenCentral() }
|
||||
}
|
||||
subprojects {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Collects what the panel queued for this server (a teleport request or message sent from the map, an invitation
|
||||
* notice) every few seconds, and carries it out with the server's normal rules.
|
||||
*/
|
||||
public final class ActionPoller {
|
||||
/** Teleport requests use each platform's own TPA, so they obey its rules and permissions. */
|
||||
public interface Tpa { void request(CorePlayer requester, CorePlayer target); }
|
||||
|
||||
public static final long EVERY_MS = 3_000;
|
||||
|
||||
private final Env env;
|
||||
private final Tpa tpa;
|
||||
private long next;
|
||||
private volatile boolean running;
|
||||
|
||||
public ActionPoller(Env env, Tpa tpa) { this.env = env; this.tpa = tpa; }
|
||||
|
||||
/** Call often (every tick or second) on the server thread; it asks the panel at most every few seconds. */
|
||||
public void tick() {
|
||||
long now = env.clock.getAsLong();
|
||||
if (running || now < next) return;
|
||||
next = now + EVERY_MS;
|
||||
running = true;
|
||||
env.platform.runAsync(() -> {
|
||||
try {
|
||||
JsonElement answer = env.panel.call("actions/poll", new JsonObject());
|
||||
JsonArray actions = answer.isJsonObject() && answer.getAsJsonObject().has("actions") ? answer.getAsJsonObject().getAsJsonArray("actions") : new JsonArray();
|
||||
if (actions.size() > 0) env.platform.runMain(() -> actions.forEach(a -> apply(a.getAsJsonObject())));
|
||||
} catch (Exception e) {
|
||||
// The panel is briefly unreachable: try again on the next round.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; }
|
||||
|
||||
private Optional<CorePlayer> player(String uuid) {
|
||||
try { return env.platform.player(UUID.fromString(uuid)); } catch (IllegalArgumentException e) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
void apply(JsonObject a) {
|
||||
String kind = text(a, "kind");
|
||||
Optional<CorePlayer> to = player(text(a, "to_uuid"));
|
||||
if (to.isEmpty()) return; // left the server since it was queued
|
||||
switch (kind) {
|
||||
case "notify" -> to.get().send(text(a, "text"));
|
||||
case "message" -> to.get().send(Format.GRAY + "[" + Format.AQUA + "Map" + Format.GRAY + "] " + Format.YELLOW + text(a, "from_name") + Format.GRAY + ": " + Format.WHITE + text(a, "text"));
|
||||
case "tpa" -> {
|
||||
Optional<CorePlayer> from = player(text(a, "from_uuid"));
|
||||
if (from.isEmpty()) { to.get().send(Format.GRAY + text(a, "from_name") + " asked to teleport to you, but has left."); return; }
|
||||
if (!from.get().hasPermission("scopenet.command.tpa")) { from.get().send(Format.RED + "You do not have permission to send teleport requests."); return; }
|
||||
tpa.request(from.get(), to.get());
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,16 @@ public final class ClientLink {
|
||||
send(p, m);
|
||||
}
|
||||
|
||||
/** Ask the client mod to open one of its windows ("market" or "shop"). Returns false if the player has no client mod. */
|
||||
public boolean open(CorePlayer p, String screen) {
|
||||
if (!clients.contains(p.uuid())) return false;
|
||||
JsonObject m = new JsonObject();
|
||||
m.addProperty("t", "open");
|
||||
m.addProperty("screen", screen);
|
||||
send(p, m);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A toast for the client (level-up, achievement, friend request…). */
|
||||
public void notify(CorePlayer p, String kind, String title, String text) {
|
||||
if (!clients.contains(p.uuid())) return;
|
||||
|
||||
@@ -8,15 +8,23 @@ import java.util.List;
|
||||
public final class CommandSet {
|
||||
public final EssentialsService essentials;
|
||||
public final Jobs jobs;
|
||||
public final ShopPoints shops;
|
||||
private final GuildCommands guildCommands;
|
||||
private final EconomyCommands economyCommands;
|
||||
private final List<CoreCommand> commands = new ArrayList<>();
|
||||
private final Limits.Cooldowns cooldowns = new Limits.Cooldowns();
|
||||
|
||||
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
|
||||
Path dir = env.dataDir;
|
||||
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
|
||||
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
|
||||
EconomyCommands economy = new EconomyCommands(env, jobs);
|
||||
shops = new ShopPoints(dir.resolve("shops.json"));
|
||||
EconomyCommands economy = new EconomyCommands(env, jobs, shops);
|
||||
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
|
||||
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
|
||||
economyCommands = economy;
|
||||
guildCommands = guilds;
|
||||
add(env, EssentialsCommands.build(env, essentials).stream().<CoreCommand>map(c -> new Cooled(c, essentials, cooldowns, env.clock)).toList(),
|
||||
"Essentials", () -> env.features.essentials());
|
||||
add(env, economy.build(), "Economy", () -> env.features.economy());
|
||||
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
|
||||
}
|
||||
@@ -27,9 +35,51 @@ public final class CommandSet {
|
||||
|
||||
public List<CoreCommand> all() { return List.copyOf(commands); }
|
||||
|
||||
/** Run a command by name as this player, with the same checks as typing it. */
|
||||
public void run(String name, CorePlayer player, String... args) {
|
||||
for (CoreCommand c : commands) if (c.name().equals(name)) { c.run(player, args); return; }
|
||||
throw new IllegalArgumentException("no command " + name);
|
||||
}
|
||||
|
||||
/** What this command set knows that the map can show. */
|
||||
public net.scopenet.core.map.MapSource mapSource() {
|
||||
return new net.scopenet.core.map.MapSource() {
|
||||
@Override public java.util.Map<String, Pos> warps() { return essentials.allWarps(); }
|
||||
@Override public java.util.Map<java.util.UUID, java.util.Map<String, Pos>> homes() { return essentials.allHomes(); }
|
||||
@Override public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
|
||||
@Override public java.util.Collection<ShopPoints.Shop> shops() { return shops.all(); }
|
||||
};
|
||||
}
|
||||
|
||||
/** Guild homes by guild id (for the map). */
|
||||
public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
|
||||
|
||||
/** Call about once a second from the server thread. */
|
||||
public void tick() { jobs.tick(); }
|
||||
|
||||
/**
|
||||
* Teleport commands can have a cooldown ({@code essentials.cooldowns}). It starts when the command actually teleports (or sends a
|
||||
* request), so a typo doesn't cost the player anything. Players with {@code scopenet.cooldown.bypass} skip it.
|
||||
*/
|
||||
private record Cooled(CoreCommand inner, EssentialsService essentials, Limits.Cooldowns cooldowns, java.util.function.LongSupplier clock) implements CoreCommand {
|
||||
@Override public String name() { return inner.name(); }
|
||||
@Override public List<String> aliases() { return inner.aliases(); }
|
||||
@Override public String permission() { return inner.permission(); }
|
||||
@Override public void run(CorePlayer p, String[] args) {
|
||||
int seconds = essentials.cooldownSeconds(inner.name());
|
||||
boolean cooled = seconds > 0 && !p.hasPermission("scopenet.cooldown.bypass");
|
||||
long now = clock.getAsLong();
|
||||
if (cooled) {
|
||||
long wait = cooldowns.remainingMs(p.uuid(), inner.name(), now);
|
||||
if (wait > 0) { p.send(Format.RED + "Please wait " + Limits.Cooldowns.waitText(wait) + " before using /" + inner.name() + " again."); return; }
|
||||
}
|
||||
long before = essentials.lastAction(p.uuid());
|
||||
inner.run(p, args);
|
||||
if (cooled && essentials.lastAction(p.uuid()) != before) cooldowns.start(p.uuid(), inner.name(), now, seconds);
|
||||
}
|
||||
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
|
||||
}
|
||||
|
||||
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */
|
||||
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
|
||||
@Override public String name() { return inner.name(); }
|
||||
|
||||
@@ -14,8 +14,9 @@ import java.util.*;
|
||||
final class EconomyCommands {
|
||||
private final Env env;
|
||||
private final Jobs jobs;
|
||||
private final ShopPoints shops;
|
||||
|
||||
EconomyCommands(Env env, Jobs jobs) { this.env = env; this.jobs = jobs; }
|
||||
EconomyCommands(Env env, Jobs jobs, ShopPoints shops) { this.env = env; this.jobs = jobs; this.shops = shops; }
|
||||
|
||||
List<CoreCommand> build() {
|
||||
List<CoreCommand> out = new ArrayList<>();
|
||||
@@ -24,7 +25,7 @@ final class EconomyCommands {
|
||||
out.add(Cmd.of("baltop", List.of("richest"), (p, a) -> baltop(p)));
|
||||
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(p)));
|
||||
out.add(Cmd.of("sell", List.of(), (p, a) -> sell(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("hand") : List.of()));
|
||||
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy") : List.of()));
|
||||
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy", "point") : a.length == 2 && a[0].equalsIgnoreCase("point") ? List.of("add", "remove", "list") : List.of()));
|
||||
out.add(Cmd.of("orders", List.of(), (p, a) -> orders(p)));
|
||||
out.add(Cmd.of("trade", List.of(), (p, a) -> p.send(Format.RED + "/trade needs the chest window, which isn't available on this platform yet. Use /market or /pay.")));
|
||||
out.add(Cmd.of("transactions", List.of(), (p, a) -> transactions(p)));
|
||||
@@ -120,6 +121,12 @@ final class EconomyCommands {
|
||||
|
||||
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */
|
||||
void market(CorePlayer p, String[] args, boolean asGuild) {
|
||||
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
|
||||
// The block under the player's feet: the platform-neutral stand-in for "the block you look at".
|
||||
Pos at = p.pos();
|
||||
shops.command(p, Arrays.copyOfRange(args, 1, args.length), new Pos(at.world(), at.x(), at.y() - 1, at.z(), 0, 0));
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
|
||||
if (!asGuild && !p.hasPermission("scopenet.command.market.buy")) { p.send(Format.RED + "You do not have permission to buy from the market."); return; }
|
||||
long listing;
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every tunable of homes, warps, TPA and RTP. Players can exceed {@code maxHomes} with a permission node
|
||||
* ({@code scopenet.homes.<number>}, or {@code scopenet.homes.unlimited}); {@code cooldowns} are seconds per command
|
||||
* (0 = none) and are skipped for anyone with {@code scopenet.cooldown.bypass}.
|
||||
*/
|
||||
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts, int rtpMinRadius, int maxWarps,
|
||||
int homeNameMax, Map<String, Integer> cooldowns) {
|
||||
public static final int TIER_CAP = 500;
|
||||
|
||||
public EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
|
||||
this(maxHomes, rtpRadius, tpaTimeoutMs, rtpAttempts, 0, 0, 32, Map.of());
|
||||
}
|
||||
|
||||
public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
|
||||
|
||||
public int cooldown(String command) { return cooldowns == null ? 0 : cooldowns.getOrDefault(command, 0); }
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public final class EssentialsService {
|
||||
|
||||
public void configure(EssentialsConfig next) { this.config = next; }
|
||||
|
||||
public int cooldownSeconds(String command) { return config.cooldown(command); }
|
||||
|
||||
// ---- persistence -------------------------------------------------------
|
||||
|
||||
private void load() {
|
||||
@@ -113,8 +115,10 @@ public final class EssentialsService {
|
||||
public void setHome(CorePlayer p, String[] args) {
|
||||
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
|
||||
Map<String, Pos> mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>());
|
||||
if (!mine.containsKey(name) && mine.size() >= config.maxHomes()) {
|
||||
p.send(RED + "You have reached your limit of " + config.maxHomes() + " homes.");
|
||||
if (name.length() > config.homeNameMax()) { p.send(RED + "Home names can be at most " + config.homeNameMax() + " characters."); return; }
|
||||
int limit = Limits.tier(p::hasPermission, "scopenet.homes.", config.maxHomes(), EssentialsConfig.TIER_CAP);
|
||||
if (!mine.containsKey(name) && mine.size() >= limit) {
|
||||
p.send(RED + "You have reached your limit of " + Limits.show(limit) + " homes.");
|
||||
return;
|
||||
}
|
||||
mine.put(name, p.pos());
|
||||
@@ -132,6 +136,16 @@ public final class EssentialsService {
|
||||
|
||||
// ---- warps -------------------------------------------------------------
|
||||
|
||||
/** Every warp, for the map. A copy. */
|
||||
public Map<String, Pos> allWarps() { return new TreeMap<>(warps); }
|
||||
|
||||
/** Every player's homes, for the map (only shown when the admin turns that on). A copy. */
|
||||
public Map<UUID, Map<String, Pos>> allHomes() {
|
||||
Map<UUID, Map<String, Pos>> out = new HashMap<>();
|
||||
homes.forEach((id, h) -> out.put(id, new TreeMap<>(h)));
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); }
|
||||
|
||||
public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); }
|
||||
@@ -151,6 +165,10 @@ public final class EssentialsService {
|
||||
}
|
||||
|
||||
public void setWarp(CorePlayer p, String name) {
|
||||
if (config.maxWarps() > 0 && !warps.containsKey(name.toLowerCase(Locale.ROOT)) && warps.size() >= config.maxWarps()) {
|
||||
p.send(RED + "The server already has " + config.maxWarps() + " warps. Delete one first.");
|
||||
return;
|
||||
}
|
||||
warps.put(name.toLowerCase(Locale.ROOT), p.pos());
|
||||
save();
|
||||
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set.");
|
||||
@@ -180,6 +198,7 @@ public final class EssentialsService {
|
||||
CorePlayer target = found.get();
|
||||
if (target.uuid().equals(p.uuid())) { p.send(RED + "You cannot teleport to yourself."); return; }
|
||||
pendingTpa.put(target.uuid(), Map.entry(p.uuid(), clock.getAsLong()));
|
||||
lastAction.put(p.uuid(), clock.getAsLong());
|
||||
p.send(GREEN + "Teleport request sent to " + YELLOW + target.name() + GREEN + ".");
|
||||
target.send(YELLOW + p.name() + GREEN + " has sent you a teleport request.");
|
||||
target.send(GRAY + "Type " + YELLOW + "/tpaccept" + GRAY + " to accept or " + RED + "/tpdeny" + GRAY
|
||||
@@ -213,11 +232,13 @@ public final class EssentialsService {
|
||||
/** Must run on the server thread, because it reads chunks. */
|
||||
public void rtp(CorePlayer p) {
|
||||
int radius = config.rtpRadius();
|
||||
int min = Math.min(config.rtpMinRadius(), radius - 1);
|
||||
String world = p.pos().world();
|
||||
p.send(GRAY + "Searching for a safe wilderness destination...");
|
||||
for (int i = 0; i < config.rtpAttempts(); i++) {
|
||||
int x = rng.nextInt(radius * 2) - radius;
|
||||
int z = rng.nextInt(radius * 2) - radius;
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < min) continue; // too close to the centre
|
||||
Optional<Pos> spot = platform.safeSurface(world, x, z);
|
||||
if (spot.isPresent()) {
|
||||
Pos at = spot.get();
|
||||
@@ -232,7 +253,13 @@ public final class EssentialsService {
|
||||
/** Teleport and remember where they were for /back. */
|
||||
public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); }
|
||||
|
||||
/** When a player last teleported or sent a teleport request (for cooldowns). */
|
||||
public long lastAction(UUID id) { return lastAction.getOrDefault(id, -1L); }
|
||||
|
||||
private final Map<UUID, Long> lastAction = new ConcurrentHashMap<>();
|
||||
|
||||
private void go(CorePlayer p, Pos destination) {
|
||||
lastAction.put(p.uuid(), clock.getAsLong());
|
||||
remember(p.uuid(), p.pos());
|
||||
p.teleport(destination);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
|
||||
final class GuildCommands {
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay");
|
||||
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline");
|
||||
|
||||
private final Env env;
|
||||
private final Jobs jobs;
|
||||
@@ -39,7 +39,8 @@ final class GuildCommands {
|
||||
List<CoreCommand> build() {
|
||||
List<CoreCommand> out = new ArrayList<>();
|
||||
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
|
||||
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay") : List.of()));
|
||||
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline")
|
||||
: a.length == 2 && a[0].equalsIgnoreCase("invite") ? EssentialsCommands.names(env, p) : List.of()));
|
||||
out.add(Cmd.of("claim", List.of(), (p, a) -> claim(p)));
|
||||
out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p)));
|
||||
return out;
|
||||
@@ -78,6 +79,9 @@ final class GuildCommands {
|
||||
case "members" -> members(p);
|
||||
case "bank" -> bank(p, rest);
|
||||
case "pay" -> pay(p, rest);
|
||||
case "invite" -> invite(p, rest);
|
||||
case "accept" -> answer(p, rest, true);
|
||||
case "decline", "deny" -> answer(p, rest, false);
|
||||
case "sell" -> economy.sell(p, rest, true);
|
||||
case "market" -> economy.market(p, rest, true);
|
||||
default -> help(p);
|
||||
@@ -93,6 +97,7 @@ final class GuildCommands {
|
||||
{"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw <amount>]", "Guild money"},
|
||||
{"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell <price> | buy <#id>", "Trade on the market as a guild"},
|
||||
{"/guild pay <tag> <amount>", "Pay another guild"},
|
||||
{"/guild invite <player>", "Invite a player (leaders and officers)"}, {"/guild accept [tag] | decline [tag]", "Answer a guild invitation"},
|
||||
};
|
||||
for (String[] r : rows) p.send(Format.YELLOW + r[0] + Format.GRAY + " - " + r[1]);
|
||||
}
|
||||
@@ -254,6 +259,30 @@ final class GuildCommands {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- invitations ------------------------------------------------------------
|
||||
|
||||
private void invite(CorePlayer p, String[] args) {
|
||||
if (args.length < 1) { p.send(Format.RED + "Usage: /guild invite <player>"); return; }
|
||||
JsonObject body = uuidBody(p);
|
||||
body.addProperty("target", args[0]);
|
||||
env.io(() -> env.panel.call("guilds/invite/send", body).getAsJsonObject(),
|
||||
r -> p.send(Format.GREEN + "Invitation sent to " + Format.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[0]) + Format.GREEN + "."),
|
||||
e -> p.send(Format.RED + "Could not invite: " + e));
|
||||
}
|
||||
|
||||
private void answer(CorePlayer p, String[] args, boolean accept) {
|
||||
JsonObject body = uuidBody(p);
|
||||
body.addProperty("tag", args.length > 0 ? args[0] : "");
|
||||
body.addProperty("accept", accept);
|
||||
env.io(() -> { JsonObject r = env.panel.call("guilds/invite/respond", body).getAsJsonObject(); if (accept) env.panel.claimsChanged(); return r; },
|
||||
r -> p.send(accept ? Format.GREEN + "Welcome to " + Format.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + Format.GREEN + "!"
|
||||
: Format.YELLOW + "Invitation declined."),
|
||||
e -> p.send(Format.RED + e));
|
||||
}
|
||||
|
||||
/** Guild homes by guild id, for the map. */
|
||||
Map<String, Pos> guildHomes() { return Map.copyOf(guildHomes); }
|
||||
|
||||
// ---- bank ---------------------------------------------------------------
|
||||
|
||||
private void bank(CorePlayer p, String[] args) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Per-player limits that permission nodes can raise, and command cooldowns. */
|
||||
public final class Limits {
|
||||
private Limits() {}
|
||||
|
||||
/**
|
||||
* The highest tier a player has, e.g. {@code scopenet.homes.10}, otherwise {@code base}. {@code <prefix>unlimited}
|
||||
* means no limit. Only values above {@code base} are looked for, up to {@code cap}.
|
||||
*/
|
||||
public static int tier(Predicate<String> has, String prefix, int base, int cap) {
|
||||
if (has.test(prefix + "unlimited")) return Integer.MAX_VALUE;
|
||||
for (int n = cap; n > base; n--) if (has.test(prefix + n)) return n;
|
||||
return base;
|
||||
}
|
||||
|
||||
public static String show(int limit) { return limit == Integer.MAX_VALUE ? "unlimited" : Integer.toString(limit); }
|
||||
|
||||
/** When each player may use each command again. Not thread-safe: use from the server thread. */
|
||||
public static final class Cooldowns {
|
||||
private final Map<String, Long> until = new HashMap<>();
|
||||
|
||||
public long remainingMs(UUID player, String command, long now) {
|
||||
Long end = until.get(player + ":" + command);
|
||||
return end == null ? 0 : Math.max(0, end - now);
|
||||
}
|
||||
|
||||
public void start(UUID player, String command, long now, int seconds) {
|
||||
if (seconds > 0) until.put(player + ":" + command, now + seconds * 1000L);
|
||||
if (until.size() > 4000) until.values().removeIf(end -> end < now);
|
||||
}
|
||||
|
||||
public static String waitText(long ms) { return Math.max(1, (ms + 999) / 1000) + "s"; }
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,10 @@ public interface Platform {
|
||||
void runMain(Runnable task);
|
||||
/** Run off the server thread, for network calls. */
|
||||
void runAsync(Runnable task);
|
||||
|
||||
/** Put items in the player's inventory (anything that doesn't fit drops at their feet). False if the item doesn't exist here. */
|
||||
default boolean giveItem(UUID player, String itemId, int amount) { return false; }
|
||||
|
||||
/** Run a command as the console; true if it was found and run. Called on the server thread. */
|
||||
default boolean console(String command) { return false; }
|
||||
}
|
||||
@@ -59,22 +59,22 @@ public final class PlayerCache {
|
||||
|
||||
// ---- small accessors used by placeholders and the client link -----------------------------
|
||||
|
||||
static JsonObject obj(JsonObject o, String key) {
|
||||
public static JsonObject obj(JsonObject o, String key) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
|
||||
}
|
||||
|
||||
static double num(JsonObject o, String key, double fallback) {
|
||||
public static double num(JsonObject o, String key, double fallback) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : fallback;
|
||||
}
|
||||
|
||||
static String str(JsonObject o, String key, String fallback) {
|
||||
public static String str(JsonObject o, String key, String fallback) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback;
|
||||
}
|
||||
|
||||
static JsonArray arr(JsonObject o, String key) {
|
||||
public static JsonArray arr(JsonObject o, String key) {
|
||||
JsonElement e = o == null ? null : o.get(key);
|
||||
return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Collects the rewards the panel owes to players on this server (items, permissions, groups, commands, messages) and carries
|
||||
* them out, then tells the panel what worked. The panel only hands rewards over for players who are online here.
|
||||
*/
|
||||
public final class RewardPoller {
|
||||
public static final long EVERY_MS = 5_000;
|
||||
|
||||
private final Env env;
|
||||
private volatile RewardRules rules;
|
||||
private long next;
|
||||
private volatile boolean running;
|
||||
|
||||
public RewardPoller(Env env, RewardRules rules) { this.env = env; this.rules = rules; }
|
||||
|
||||
public void rules(RewardRules rules) { this.rules = rules; }
|
||||
|
||||
/** Call often on the server thread; it asks the panel at most every few seconds. */
|
||||
public void tick() {
|
||||
if (!rules.enabled()) return;
|
||||
long now = env.clock.getAsLong();
|
||||
if (running || now < next) return;
|
||||
next = now + EVERY_MS;
|
||||
running = true;
|
||||
env.platform.runAsync(() -> {
|
||||
try {
|
||||
JsonElement answer = env.panel.call("rewards/poll", new JsonObject());
|
||||
JsonArray list = answer.isJsonObject() && answer.getAsJsonObject().has("deliveries") ? answer.getAsJsonObject().getAsJsonArray("deliveries") : new JsonArray();
|
||||
if (list.size() > 0) env.platform.runMain(() -> carryOut(list));
|
||||
} catch (Exception e) {
|
||||
// The panel is briefly unreachable: try again on the next round.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; }
|
||||
|
||||
/** Server thread. Applies each delivery, then reports back from the background. */
|
||||
void carryOut(JsonArray list) {
|
||||
JsonArray done = new JsonArray(), failed = new JsonArray();
|
||||
for (JsonElement el : list) {
|
||||
JsonObject d = el.getAsJsonObject();
|
||||
long id = d.get("id").getAsLong();
|
||||
String error;
|
||||
try {
|
||||
error = apply(d);
|
||||
} catch (RuntimeException e) {
|
||||
error = e.getClass().getSimpleName();
|
||||
}
|
||||
if (error == null) done.add(id);
|
||||
else {
|
||||
JsonObject f = new JsonObject();
|
||||
f.addProperty("id", id);
|
||||
f.addProperty("error", error);
|
||||
failed.add(f);
|
||||
}
|
||||
}
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("done", done);
|
||||
body.add("failed", failed);
|
||||
env.platform.runAsync(() -> {
|
||||
try { env.panel.call("rewards/ack", body); } catch (Exception e) { /* the panel re-offers it, and a second try is harmless */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Null when carried out, otherwise why not. */
|
||||
String apply(JsonObject d) {
|
||||
UUID uuid;
|
||||
try { uuid = UUID.fromString(text(d, "uuid")); } catch (IllegalArgumentException e) { return "bad player id"; }
|
||||
Optional<CorePlayer> player = env.platform.player(uuid);
|
||||
if (player.isEmpty()) return "player left"; // retried when they are back
|
||||
JsonObject a = d.has("payload") && d.get("payload").isJsonObject() ? d.getAsJsonObject("payload") : new JsonObject();
|
||||
String name = player.get().name();
|
||||
RewardRules r = rules;
|
||||
switch (text(d, "kind")) {
|
||||
case "item" -> {
|
||||
int amount = a.has("amount") ? a.get("amount").getAsInt() : 1;
|
||||
if (!env.platform.giveItem(uuid, text(a, "item"), amount)) return "unknown item " + text(a, "item");
|
||||
player.get().send(Format.GREEN + "You received " + Format.WHITE + amount + "x " + prettyItem(text(a, "item")) + Format.GREEN + "!");
|
||||
}
|
||||
case "permission" -> {
|
||||
boolean value = !a.has("value") || a.get("value").getAsBoolean();
|
||||
long minutes = a.has("minutes") ? a.get("minutes").getAsLong() : 0;
|
||||
String template = minutes > 0 ? r.permissionTempCommand() : value ? r.permissionCommand() : r.permissionDenyCommand();
|
||||
String command = template.replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{node}", text(a, "node"))
|
||||
.replace("{value}", Boolean.toString(value)).replace("{duration}", minutes + "m");
|
||||
if (!env.platform.console(command)) return "the permissions command did not run (is LuckPerms installed?)";
|
||||
player.get().send(Format.GREEN + "You gained a new permission: " + Format.WHITE + text(a, "node") + Format.GREEN + ".");
|
||||
}
|
||||
case "group" -> {
|
||||
String command = r.groupCommand().replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{group}", text(a, "group"));
|
||||
if (!env.platform.console(command)) return "the group command did not run (is LuckPerms installed?)";
|
||||
player.get().send(Format.GREEN + "You joined the " + Format.WHITE + text(a, "group") + Format.GREEN + " group.");
|
||||
}
|
||||
case "command" -> {
|
||||
if (!r.allowCommands()) return "custom commands are switched off on this server (rewards.allow-commands)";
|
||||
String command = text(a, "command").replace("{player}", name).replace("{uuid}", uuid.toString());
|
||||
if (!env.platform.console(command)) return "command not found";
|
||||
}
|
||||
case "message" -> player.get().send(Format.GOLD + text(a, "text"));
|
||||
default -> { return "unknown reward kind"; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String prettyItem(String id) {
|
||||
String n = id.contains(":") ? id.substring(id.indexOf(':') + 1) : id;
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (String w : n.split("[_/]")) if (!w.isEmpty()) out.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1)).append(' ');
|
||||
return out.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
/**
|
||||
* How this server carries out rewards the panel hands over. Permissions and groups go through console commands so any
|
||||
* permissions plugin works; the defaults are LuckPerms'. Custom admin commands stay off unless the server owner allows them.
|
||||
*/
|
||||
public record RewardRules(boolean enabled, boolean allowCommands, String permissionCommand, String permissionDenyCommand,
|
||||
String permissionTempCommand, String groupCommand) {
|
||||
public static RewardRules defaults() {
|
||||
return new RewardRules(true, false, "lp user {player} permission set {node} true", "lp user {player} permission set {node} false",
|
||||
"lp user {player} permission settemp {node} {value} {duration}", "lp user {player} parent add {group}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Physical shops: a block in the world that opens the market or the server shop when a player right-clicks it, and
|
||||
* shows up as a pin on the map. The GUI itself is the same as typing the command, so it works on Paper (chest window)
|
||||
* and with the client mod (shop and market windows). Set up by staff with {@code /market point add|remove|list}.
|
||||
*/
|
||||
public final class ShopPoints {
|
||||
public record Shop(String name, String kind, Pos pos) {}
|
||||
|
||||
public static final String NODE = "scopenet.command.market.point";
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private final Path file;
|
||||
private final Map<String, Shop> shops = new ConcurrentHashMap<>();
|
||||
|
||||
public ShopPoints(Path file) {
|
||||
this.file = file;
|
||||
if (Files.exists(file)) {
|
||||
try (Reader in = Files.newBufferedReader(file)) {
|
||||
List<Shop> loaded = GSON.fromJson(in, new TypeToken<List<Shop>>() {}.getType());
|
||||
if (loaded != null) loaded.forEach(s -> shops.put(s.name().toLowerCase(Locale.ROOT), s));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Collection<Shop> all() { return new ArrayList<>(new TreeMap<>(shops).values()); }
|
||||
|
||||
/** The shop at this block, if any. */
|
||||
public Optional<Shop> at(String dimension, int x, int y, int z) {
|
||||
for (Shop s : shops.values()) {
|
||||
Pos p = s.pos();
|
||||
if (p.world().equals(dimension) && p.blockX() == x && p.blockY() == y && p.blockZ() == z) return Optional.of(s);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
if (file.getParent() != null) Files.createDirectories(file.getParent());
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(all(), w); }
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code /market point add <name> [market|shop]} marks the block the player is looking at... or, where the platform can't
|
||||
* tell, the block they stand on. {@code target} is that block. {@code remove <name>} and {@code list} as expected.
|
||||
*/
|
||||
public void command(CorePlayer p, String[] args, Pos target) {
|
||||
if (!p.hasPermission(NODE)) { p.send(Format.RED + "You do not have permission to manage shop points."); return; }
|
||||
String sub = args.length == 0 ? "list" : args[0].toLowerCase(Locale.ROOT);
|
||||
switch (sub) {
|
||||
case "add" -> {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /market point add <name> [market|shop]"); return; }
|
||||
String name = args[1];
|
||||
if (!name.matches("[A-Za-z0-9_-]{1,24}")) { p.send(Format.RED + "Names are 1-24 letters, digits, - or _."); return; }
|
||||
String kind = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : "market";
|
||||
if (!kind.equals("market") && !kind.equals("shop")) { p.send(Format.RED + "The kind is market or shop."); return; }
|
||||
if (at(target.world(), target.blockX(), target.blockY(), target.blockZ()).isPresent()) { p.send(Format.RED + "There is already a shop point on that block."); return; }
|
||||
Pos block = new Pos(target.world(), target.blockX() + 0.5, target.blockY(), target.blockZ() + 0.5, 0, 0);
|
||||
shops.put(name.toLowerCase(Locale.ROOT), new Shop(name, kind, block));
|
||||
save();
|
||||
p.send(Format.GREEN + "Shop point " + Format.YELLOW + name + Format.GREEN + " set (" + kind + "). Right-click that block to open the "
|
||||
+ (kind.equals("market") ? "market" : "shop") + ".");
|
||||
}
|
||||
case "remove", "delete" -> {
|
||||
if (args.length < 2) { p.send(Format.RED + "Usage: /market point remove <name>"); return; }
|
||||
if (shops.remove(args[1].toLowerCase(Locale.ROOT)) == null) { p.send(Format.RED + "No shop point called '" + args[1] + "'."); return; }
|
||||
save();
|
||||
p.send(Format.GREEN + "Shop point " + Format.YELLOW + args[1] + Format.GREEN + " removed.");
|
||||
}
|
||||
case "list" -> {
|
||||
p.send(Format.GOLD + "=== Shop points ===");
|
||||
if (shops.isEmpty()) p.send(Format.GRAY + "None yet. Look at a block and use /market point add <name>.");
|
||||
for (Shop s : all()) p.send(Format.YELLOW + s.name() + Format.GRAY + " (" + s.kind() + ") " + s.pos().blockX() + ", " + s.pos().blockY() + ", " + s.pos().blockZ());
|
||||
}
|
||||
default -> p.send(Format.RED + "Usage: /market point add <name> [market|shop] | remove <name> | list");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Turns a set of claimed chunks into clean outlines for a map: one outer ring per connected area, plus a ring for
|
||||
* every hole inside it. Coordinates are block coordinates (chunk x 16). Pure and fast: each chunk adds at most four edges.
|
||||
*/
|
||||
public final class ChunkRegions {
|
||||
private ChunkRegions() {}
|
||||
|
||||
public record Point(double x, double z) {}
|
||||
|
||||
/** A closed loop; the last point connects back to the first. */
|
||||
public record Ring(List<Point> points) {
|
||||
public double area() {
|
||||
double sum = 0;
|
||||
for (int i = 0; i < points.size(); i++) {
|
||||
Point a = points.get(i), b = points.get((i + 1) % points.size());
|
||||
sum += a.x() * b.z() - b.x() * a.z();
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
public boolean contains(double x, double z) {
|
||||
boolean inside = false;
|
||||
for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) {
|
||||
Point a = points.get(i), b = points.get(j);
|
||||
if ((a.z() > z) != (b.z() > z) && x < (b.x() - a.x()) * (z - a.z()) / (b.z() - a.z()) + a.x()) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/** One connected area: its outline, the holes in it, how many chunks it has and a good spot for its label. */
|
||||
public record Region(Ring outer, List<Ring> holes, int chunks, double labelX, double labelZ) {}
|
||||
|
||||
public static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
static int kx(long key) { return (int) (key >> 32); }
|
||||
static int kz(long key) { return (int) key; }
|
||||
|
||||
/** Split into areas that touch along an edge. */
|
||||
static List<Set<Long>> components(Set<Long> chunks) {
|
||||
List<Set<Long>> out = new ArrayList<>();
|
||||
Set<Long> seen = new HashSet<>();
|
||||
List<Long> sorted = new ArrayList<>(chunks);
|
||||
Collections.sort(sorted);
|
||||
for (long start : sorted) {
|
||||
if (!seen.add(start)) continue;
|
||||
Set<Long> group = new HashSet<>();
|
||||
Deque<Long> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
group.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
long c = queue.poll();
|
||||
int x = kx(c), z = kz(c);
|
||||
for (long n : new long[]{key(x + 1, z), key(x - 1, z), key(x, z + 1), key(x, z - 1)}) {
|
||||
if (chunks.contains(n) && seen.add(n)) { group.add(n); queue.add(n); }
|
||||
}
|
||||
}
|
||||
out.add(group);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static List<Region> regions(Set<Long> chunks) {
|
||||
List<Region> out = new ArrayList<>();
|
||||
for (Set<Long> group : components(chunks)) {
|
||||
List<Ring> outers = new ArrayList<>(), holes = new ArrayList<>();
|
||||
for (Ring r : outline(group)) (r.area() > 0 ? outers : holes).add(r);
|
||||
double[] label = labelSpot(group);
|
||||
for (Ring o : outers) {
|
||||
List<Ring> mine = new ArrayList<>();
|
||||
for (Ring h : holes) if (outers.size() == 1 || o.contains(average(h, true), average(h, false))) mine.add(h);
|
||||
out.add(new Region(o, mine, group.size(), label[0], label[1]));
|
||||
}
|
||||
}
|
||||
out.sort(Comparator.comparingInt(Region::chunks).reversed().thenComparingDouble(r -> r.labelX()).thenComparingDouble(r -> r.labelZ()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double average(Ring ring, boolean x) {
|
||||
double sum = 0;
|
||||
for (Point p : ring.points()) sum += x ? p.x() : p.z();
|
||||
return sum / ring.points().size();
|
||||
}
|
||||
|
||||
/** The middle of the chunk nearest to the area's average, so a label never lands outside a bent shape. */
|
||||
static double[] labelSpot(Set<Long> group) {
|
||||
double mx = 0, mz = 0;
|
||||
for (long c : group) { mx += kx(c) + 0.5; mz += kz(c) + 0.5; }
|
||||
mx /= group.size();
|
||||
mz /= group.size();
|
||||
long best = group.iterator().next();
|
||||
double bestD = Double.MAX_VALUE;
|
||||
for (long c : group) {
|
||||
double d = Math.pow(kx(c) + 0.5 - mx, 2) + Math.pow(kz(c) + 0.5 - mz, 2);
|
||||
if (d < bestD || (d == bestD && c < best)) { bestD = d; best = c; }
|
||||
}
|
||||
return new double[]{(kx(best) + 0.5) * 16, (kz(best) + 0.5) * 16};
|
||||
}
|
||||
|
||||
private record Edge(int fx, int fz, int tx, int tz) {}
|
||||
|
||||
/** Boundary loops of one connected area. Outer loops have positive area, holes negative. */
|
||||
static List<Ring> outline(Set<Long> group) {
|
||||
// Directed edges with the chunk on the right-hand side (clockwise around solid land).
|
||||
Map<Long, List<Edge>> from = new HashMap<>();
|
||||
for (long c : group) {
|
||||
int x = kx(c), z = kz(c);
|
||||
if (!group.contains(key(x, z - 1))) add(from, new Edge(x, z, x + 1, z));
|
||||
if (!group.contains(key(x + 1, z))) add(from, new Edge(x + 1, z, x + 1, z + 1));
|
||||
if (!group.contains(key(x, z + 1))) add(from, new Edge(x + 1, z + 1, x, z + 1));
|
||||
if (!group.contains(key(x - 1, z))) add(from, new Edge(x, z + 1, x, z));
|
||||
}
|
||||
List<Ring> rings = new ArrayList<>();
|
||||
while (!from.isEmpty()) {
|
||||
// Start from a stable edge so results don't depend on hash order.
|
||||
long startKey = Collections.min(from.keySet());
|
||||
Edge first = from.get(startKey).get(0);
|
||||
List<int[]> loop = new ArrayList<>();
|
||||
Edge e = first;
|
||||
do {
|
||||
remove(from, e);
|
||||
loop.add(new int[]{e.fx(), e.fz()});
|
||||
e = next(from, e);
|
||||
if (e == null) break;
|
||||
} while (!(e.fx() == first.fx() && e.fz() == first.fz()));
|
||||
rings.add(new Ring(simplify(loop)));
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
private static void add(Map<Long, List<Edge>> m, Edge e) { m.computeIfAbsent(key(e.fx(), e.fz()), k -> new ArrayList<>()).add(e); }
|
||||
|
||||
private static void remove(Map<Long, List<Edge>> m, Edge e) {
|
||||
List<Edge> list = m.get(key(e.fx(), e.fz()));
|
||||
list.remove(e);
|
||||
if (list.isEmpty()) m.remove(key(e.fx(), e.fz()));
|
||||
}
|
||||
|
||||
/** The edge leaving this edge's end. Where two loops touch at a corner, turn right to keep them apart. */
|
||||
private static Edge next(Map<Long, List<Edge>> m, Edge e) {
|
||||
List<Edge> options = m.get(key(e.tx(), e.tz()));
|
||||
if (options == null || options.isEmpty()) return null;
|
||||
if (options.size() == 1) return options.get(0);
|
||||
int dx = e.tx() - e.fx(), dz = e.tz() - e.fz();
|
||||
Edge best = options.get(0);
|
||||
int bestScore = Integer.MIN_VALUE;
|
||||
for (Edge o : options) {
|
||||
int ox = o.tx() - o.fx(), oz = o.tz() - o.fz();
|
||||
int cross = dx * oz - dz * ox; // >0: turns right in (x right, z down) coordinates
|
||||
int score = cross > 0 ? 2 : cross == 0 ? 1 : 0;
|
||||
if (score > bestScore) { bestScore = score; best = o; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Drop corner points that sit in a straight line, then scale chunks to blocks. */
|
||||
private static List<Point> simplify(List<int[]> loop) {
|
||||
List<int[]> kept = new ArrayList<>();
|
||||
int n = loop.size();
|
||||
for (int i = 0; i < n; i++) {
|
||||
int[] prev = loop.get((i + n - 1) % n), cur = loop.get(i), nxt = loop.get((i + 1) % n);
|
||||
int cross = (cur[0] - prev[0]) * (nxt[1] - cur[1]) - (cur[1] - prev[1]) * (nxt[0] - cur[0]);
|
||||
if (cross != 0) kept.add(cur);
|
||||
}
|
||||
List<Point> out = new ArrayList<>();
|
||||
for (int[] p : kept) out.add(new Point(p[0] * 16.0, p[1] * 16.0));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the map shows, independent of any map software. Built by {@link MapModel}. */
|
||||
public record MapData(List<ClaimRegion> claims, List<Pin> pins, List<OnlinePlayer> players) {
|
||||
public static MapData empty() { return new MapData(List.of(), List.of(), List.of()); }
|
||||
|
||||
/** One connected area of a guild's land. {@code color} is 0xRRGGBB. */
|
||||
public record ClaimRegion(String id, String guildId, String name, String tag, String icon, int color, String dimension,
|
||||
ChunkRegions.Ring outer, List<ChunkRegions.Ring> holes, int chunks, double labelX, double labelZ, String detail) {}
|
||||
|
||||
/** kind: spawn, warp, home, guild_home, market or shop. {@code detail} is HTML that is already escaped. */
|
||||
public record Pin(String id, String kind, String label, String dimension, double x, double y, double z, String detail) {}
|
||||
|
||||
public record OnlinePlayer(String uuid, String name, long level, String title, long serverLevel, String guild, String guildTag,
|
||||
String guildRole, String rank, long playtimeSecs) {}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.Pos;
|
||||
import net.scopenet.core.ShopPoints;
|
||||
import net.scopenet.integration.ClaimIndex;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Builds {@link MapData} from what the server already knows: claims, warps, spawn, shop points, listings and players. */
|
||||
public final class MapModel {
|
||||
private MapModel() {}
|
||||
|
||||
/** Everything the map is built from. All of it must be safe to read from another thread. */
|
||||
public record Sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Map<String, Pos> warps, Collection<Pos> spawns,
|
||||
Map<UUID, Map<String, Pos>> homes, Map<String, NamedPos> guildHomes, Collection<ShopPoints.Shop> shops,
|
||||
JsonArray market, List<MapData.OnlinePlayer> players, String panelBase, boolean showHomes,
|
||||
Map<UUID, String> homeOwners) {}
|
||||
|
||||
public record NamedPos(String label, Pos pos) {}
|
||||
|
||||
public static String escape(String text) {
|
||||
if (text == null) return "";
|
||||
StringBuilder out = new StringBuilder(text.length() + 8);
|
||||
for (char c : text.toCharArray()) {
|
||||
switch (c) {
|
||||
case '&' -> out.append("&");
|
||||
case '<' -> out.append("<");
|
||||
case '>' -> out.append(">");
|
||||
case '"' -> out.append(""");
|
||||
case '\'' -> out.append("'");
|
||||
default -> { if (c >= 32 || c == '\n') out.append(c); }
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** A stable, pleasant colour per guild: hue from the id, fixed saturation and lightness. */
|
||||
public static int guildColor(String guildId) {
|
||||
int h = guildId.hashCode();
|
||||
double hue = Math.floorMod(h, 360);
|
||||
return hsl(hue, 0.65, 0.55);
|
||||
}
|
||||
|
||||
static int hsl(double h, double s, double l) {
|
||||
double c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c / 2;
|
||||
double r, g, b;
|
||||
if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; }
|
||||
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
|
||||
}
|
||||
|
||||
/** Which Minecraft dimension a world id or name means ("world_nether" -> the nether). */
|
||||
public static String dimensionOf(String id, String name) {
|
||||
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
|
||||
if (words.contains("nether")) return "minecraft:the_nether";
|
||||
if (words.contains("end")) return "minecraft:the_end";
|
||||
return "minecraft:overworld";
|
||||
}
|
||||
|
||||
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
|
||||
public static String iconAddress(String icon, String panelBase) {
|
||||
if (icon == null || icon.isBlank()) return "";
|
||||
String i = icon.trim();
|
||||
if (i.startsWith("https://") || i.startsWith("http://")) return i;
|
||||
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
|
||||
return "";
|
||||
}
|
||||
|
||||
public static MapData build(Sources s) {
|
||||
List<MapData.ClaimRegion> claims = new ArrayList<>();
|
||||
s.claims().forEach((dimension, guilds) -> {
|
||||
for (ClaimIndex.GuildClaims g : guilds) {
|
||||
int n = 0;
|
||||
for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) {
|
||||
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
|
||||
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
|
||||
claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()),
|
||||
guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
List<MapData.Pin> pins = new ArrayList<>();
|
||||
int i = 0;
|
||||
for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
|
||||
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
|
||||
s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(),
|
||||
home.pos().x(), home.pos().y(), home.pos().z(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
|
||||
if (s.showHomes()) {
|
||||
s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> {
|
||||
String who = s.homeOwners().getOrDefault(owner, "a player");
|
||||
pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(),
|
||||
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
|
||||
}));
|
||||
}
|
||||
String marketDetail = marketSummary(s.market());
|
||||
for (ShopPoints.Shop shop : s.shops()) {
|
||||
boolean market = shop.kind().equals("market");
|
||||
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
|
||||
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
|
||||
Pos p = shop.pos();
|
||||
pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail));
|
||||
}
|
||||
pins.sort(Comparator.comparing(MapData.Pin::id));
|
||||
return new MapData(claims, pins, s.players());
|
||||
}
|
||||
|
||||
/** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */
|
||||
static String marketSummary(JsonArray listings) {
|
||||
if (listings == null || listings.size() == 0) return "<br><em>No listings right now.</em>";
|
||||
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
|
||||
int shown = 0;
|
||||
for (JsonElement e : listings) {
|
||||
if (shown++ >= 5) break;
|
||||
JsonObject l = e.getAsJsonObject();
|
||||
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name");
|
||||
b.append("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
|
||||
.append(" · ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0))
|
||||
.append(" <span>by ").append(escape(guild)).append("</span></li>");
|
||||
}
|
||||
return b.append("</ul>").toString();
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String key) { return o.has(key) && o.get(key).isJsonPrimitive() ? o.get(key).getAsString() : ""; }
|
||||
|
||||
/** The file the map page reads to describe players who are online (public facts only: never balances). */
|
||||
public static JsonObject playersJson(String server, List<MapData.OnlinePlayer> players, long now) {
|
||||
JsonObject root = new JsonObject();
|
||||
root.addProperty("updated", now);
|
||||
root.addProperty("server", server);
|
||||
JsonObject byId = new JsonObject();
|
||||
for (MapData.OnlinePlayer p : players) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("level", p.level());
|
||||
o.addProperty("title", p.title());
|
||||
o.addProperty("serverLevel", p.serverLevel());
|
||||
o.addProperty("guild", p.guild());
|
||||
o.addProperty("guildTag", p.guildTag());
|
||||
o.addProperty("guildRole", p.guildRole());
|
||||
o.addProperty("rank", p.rank());
|
||||
o.addProperty("playtime", p.playtimeSecs());
|
||||
byId.add(p.uuid(), o);
|
||||
}
|
||||
root.add("players", byId);
|
||||
return root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text
|
||||
* lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup.
|
||||
*/
|
||||
public final class MapOverlay {
|
||||
private MapOverlay() {}
|
||||
|
||||
public static JsonObject toJson(MapData data) {
|
||||
JsonArray claims = new JsonArray();
|
||||
for (MapData.ClaimRegion c : data.claims()) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", c.id());
|
||||
o.addProperty("guild_id", c.guildId());
|
||||
o.addProperty("name", c.name());
|
||||
o.addProperty("tag", c.tag());
|
||||
o.addProperty("icon", c.icon());
|
||||
o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF));
|
||||
o.addProperty("dimension", c.dimension());
|
||||
o.addProperty("chunks", c.chunks());
|
||||
o.addProperty("label_x", c.labelX());
|
||||
o.addProperty("label_z", c.labelZ());
|
||||
o.add("outer", ring(c.outer()));
|
||||
JsonArray holes = new JsonArray();
|
||||
for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole));
|
||||
o.add("holes", holes);
|
||||
o.add("lines", lines(c.detail()));
|
||||
claims.add(o);
|
||||
}
|
||||
JsonArray pins = new JsonArray();
|
||||
for (MapData.Pin p : data.pins()) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", p.id());
|
||||
o.addProperty("kind", p.kind());
|
||||
o.addProperty("label", p.label());
|
||||
o.addProperty("dimension", p.dimension());
|
||||
o.addProperty("x", p.x());
|
||||
o.addProperty("y", p.y());
|
||||
o.addProperty("z", p.z());
|
||||
o.add("lines", lines(p.detail()));
|
||||
pins.add(o);
|
||||
}
|
||||
JsonObject root = new JsonObject();
|
||||
root.add("claims", claims);
|
||||
root.add("pins", pins);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JsonArray ring(ChunkRegions.Ring ring) {
|
||||
JsonArray out = new JsonArray();
|
||||
for (ChunkRegions.Point p : ring.points()) {
|
||||
JsonArray pair = new JsonArray();
|
||||
pair.add(p.x());
|
||||
pair.add(p.z());
|
||||
out.add(pair);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */
|
||||
static JsonArray lines(String html) {
|
||||
String text = html == null ? "" : html
|
||||
.replaceAll("(?i)<br\\s*/?>|</div>|</li>|</ul>|<ul[^>]*>", "\n")
|
||||
.replaceAll("<[^>]*>", "");
|
||||
text = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.replace("·", "·").replace("&", "&");
|
||||
JsonArray out = new JsonArray();
|
||||
for (String line : text.split("\n")) {
|
||||
String t = line.trim();
|
||||
if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.*;
|
||||
import net.scopenet.integration.ClaimIndex;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Gathers everything the map needs, from any thread. The map overlay asks for a {@link MapData} every
|
||||
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
|
||||
*/
|
||||
public final class MapService {
|
||||
public static final long MARKET_REFRESH_MS = 60_000;
|
||||
|
||||
private final Env env;
|
||||
private final ClaimIndex claims;
|
||||
private final MapSource places;
|
||||
private final PlayerCache cache;
|
||||
private final String panelBase;
|
||||
private final boolean showHomes;
|
||||
private final String serverName;
|
||||
private volatile JsonArray market = new JsonArray();
|
||||
private volatile long marketAt = Long.MIN_VALUE / 2;
|
||||
private volatile boolean fetching;
|
||||
|
||||
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
|
||||
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
|
||||
}
|
||||
|
||||
public String serverName() { return serverName; }
|
||||
|
||||
/** Server thread, about once a second. */
|
||||
public void tick() {
|
||||
long now = env.clock.getAsLong();
|
||||
if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return;
|
||||
fetching = true;
|
||||
marketAt = now;
|
||||
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
|
||||
market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||
fetching = false;
|
||||
}, e -> fetching = false);
|
||||
}
|
||||
|
||||
/** Safe from any thread: reads only copies and concurrent collections. */
|
||||
public MapData snapshot() {
|
||||
List<MapData.OnlinePlayer> players = onlinePlayers();
|
||||
Map<UUID, String> names = new HashMap<>();
|
||||
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
|
||||
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
|
||||
Map<String, String> labels = new HashMap<>();
|
||||
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
|
||||
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
|
||||
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
|
||||
MapModel.Sources sources = new MapModel.Sources(
|
||||
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
|
||||
env.features.essentials() ? places.warps() : Map.of(),
|
||||
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
|
||||
market, players, panelBase, showHomes, names);
|
||||
return MapModel.build(sources);
|
||||
}
|
||||
|
||||
private List<MapData.OnlinePlayer> onlinePlayers() {
|
||||
List<MapData.OnlinePlayer> out = new ArrayList<>();
|
||||
for (CorePlayer p : env.platform.online()) {
|
||||
JsonObject i = cache.info(p.uuid());
|
||||
JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank");
|
||||
out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""),
|
||||
(long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""),
|
||||
PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.scopenet.core.map;
|
||||
|
||||
import net.scopenet.core.Pos;
|
||||
import net.scopenet.core.ShopPoints;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
|
||||
public interface MapSource {
|
||||
Map<String, Pos> warps();
|
||||
Map<UUID, Map<String, Pos>> homes();
|
||||
/** Guild homes by guild id. */
|
||||
Map<String, Pos> guildHomes();
|
||||
Collection<ShopPoints.Shop> shops();
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.util.UUID;
|
||||
* The panel is polled for a new copy only when its revision changes.
|
||||
*/
|
||||
public final class ClaimIndex {
|
||||
private record Guild(String id, String name, String tag) {}
|
||||
private record Guild(String id, String name, String tag, String icon) {}
|
||||
|
||||
private record Snapshot(String revision, Map<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {}
|
||||
|
||||
@@ -59,6 +59,26 @@ public final class ClaimIndex {
|
||||
return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag());
|
||||
}
|
||||
|
||||
/** One guild's claimed chunks in one dimension, for drawing on a map. */
|
||||
public record GuildClaims(String id, String name, String tag, String icon, Set<Long> chunks) {}
|
||||
|
||||
public static int chunkX(long key) { return (int) (key >> 32); }
|
||||
public static int chunkZ(long key) { return (int) key; }
|
||||
|
||||
/** Every claim grouped by dimension and guild (a copy; safe to use from any thread). */
|
||||
public Map<String, java.util.List<GuildClaims>> byDimension() {
|
||||
Snapshot s = snapshot;
|
||||
Map<String, java.util.List<GuildClaims>> out = new HashMap<>();
|
||||
if (s == null) return out;
|
||||
s.claims().forEach((dimension, chunks) -> {
|
||||
Map<String, GuildClaims> perGuild = new java.util.LinkedHashMap<>();
|
||||
chunks.forEach((key, guild) -> perGuild.computeIfAbsent(guild.id(),
|
||||
id -> new GuildClaims(guild.id(), guild.name(), guild.tag(), guild.icon(), new HashSet<>())).chunks().add(key));
|
||||
out.put(dimension, new java.util.ArrayList<>(perGuild.values()));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */
|
||||
public boolean apply(JsonObject response) {
|
||||
if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false;
|
||||
@@ -67,7 +87,8 @@ public final class ClaimIndex {
|
||||
Guild[] guilds = new Guild[guildList.size()];
|
||||
for (int i = 0; i < guilds.length; i++) {
|
||||
JsonObject g = guildList.get(i).getAsJsonObject();
|
||||
guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString());
|
||||
guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString(),
|
||||
g.has("icon_url") && g.get("icon_url").isJsonPrimitive() ? g.get("icon_url").getAsString() : "");
|
||||
}
|
||||
Map<String, Map<Long, Guild>> claims = new HashMap<>();
|
||||
JsonArray claimList = response.getAsJsonArray("claims");
|
||||
|
||||
@@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable {
|
||||
private final AtomicBoolean syncing = new AtomicBoolean();
|
||||
private volatile boolean closed;
|
||||
private volatile Consumer<JsonArray> notificationHandler;
|
||||
private volatile LiveMapSync liveMap;
|
||||
private volatile net.scopenet.worldmap.WorldMapSync map;
|
||||
private volatile java.util.function.Supplier<String> mapOverlay;
|
||||
private boolean registered;
|
||||
private JsonObject pending;
|
||||
private long nextSync;
|
||||
@@ -145,25 +146,33 @@ public final class Integration implements AutoCloseable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start mirroring this server's world and player positions to the panel's
|
||||
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
|
||||
* Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims
|
||||
* overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles.
|
||||
*/
|
||||
public void enableLiveMap(java.nio.file.Path worldRoot) {
|
||||
if (closed || liveMap != null) return;
|
||||
liveMap = new LiveMapSync(client, worldRoot, log);
|
||||
liveMap.start();
|
||||
public void enableMap(java.nio.file.Path worldRoot, java.nio.file.Path cacheDir) {
|
||||
if (closed || map != null) return;
|
||||
map = new net.scopenet.worldmap.WorldMapSync(client, worldRoot, cacheDir, log);
|
||||
if (mapOverlay != null) map.setOverlay(mapOverlay);
|
||||
map.start();
|
||||
}
|
||||
|
||||
/** Where the map gets its claims and pins (JSON text); may be called before or after {@link #enableMap}. */
|
||||
public void setMapOverlay(java.util.function.Supplier<String> overlay) {
|
||||
mapOverlay = overlay;
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.setOverlay(overlay);
|
||||
}
|
||||
|
||||
/** Hand over a snapshot of player positions; call about once a second on the server thread. */
|
||||
public void liveMapPlayers(java.util.List<LiveMapSync.Player> players) {
|
||||
LiveMapSync map = liveMap;
|
||||
if (map != null) map.offerPlayers(players);
|
||||
public void mapPlayers(java.util.List<net.scopenet.worldmap.WorldMapSync.Player> players) {
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.offerPlayers(players);
|
||||
}
|
||||
|
||||
/** The world was just saved: pick up its changed chunks soon. */
|
||||
public void liveMapSaved() {
|
||||
LiveMapSync map = liveMap;
|
||||
if (map != null) map.requestScan();
|
||||
/** The world was just saved: pick up its changed regions soon. */
|
||||
public void mapSaved() {
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.requestScan();
|
||||
}
|
||||
|
||||
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
||||
@@ -182,8 +191,8 @@ public final class Integration implements AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
LiveMapSync map = liveMap;
|
||||
if (map != null) map.close();
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
if (current != null) current.close();
|
||||
client.close();
|
||||
loginWorker.shutdownNow();
|
||||
activity.leaveAll();
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.WebSocket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
|
||||
* already-compressed Anvil payloads read straight from the region files) over
|
||||
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
|
||||
* Nothing here touches the server thread except {@link #offerPlayers}.
|
||||
*/
|
||||
public final class LiveMapSync implements AutoCloseable {
|
||||
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
|
||||
|
||||
private static final String[] DIMS = {"overworld", "the_nether", "the_end"};
|
||||
private static final int SECTOR = 4096;
|
||||
private static final int BATCH_BYTES = 6 * 1024 * 1024;
|
||||
private static final int SCAN_BUDGET_BYTES = 48 * 1024 * 1024;
|
||||
|
||||
private final PanelClient client;
|
||||
private final Path root;
|
||||
private final Consumer<String> log;
|
||||
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "scopenet-livemap");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
|
||||
private final Map<String, int[]> known = new HashMap<>();
|
||||
private final Set<String> manifestLoaded = new HashSet<>();
|
||||
private final AtomicBoolean scanQueued = new AtomicBoolean();
|
||||
private volatile boolean panelEnabled;
|
||||
private volatile boolean closed;
|
||||
private volatile List<Player> players = List.of();
|
||||
private volatile boolean playersChanged;
|
||||
private volatile WebSocket socket;
|
||||
private volatile long socketRetryAt;
|
||||
private long nextScan;
|
||||
private long configCheckAt;
|
||||
private long levelModified = -1;
|
||||
private long lastPlayersSent;
|
||||
private long chunkIntervalSecs = 20;
|
||||
private boolean warnedMissing;
|
||||
|
||||
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
|
||||
this.client = client;
|
||||
this.root = root;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
|
||||
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void offerPlayers(List<Player> next) {
|
||||
if (!next.equals(players)) {
|
||||
players = List.copyOf(next);
|
||||
playersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A save just happened; scan soon instead of waiting for the next interval. */
|
||||
public void requestScan() {
|
||||
if (closed || !scanQueued.compareAndSet(false, true)) return;
|
||||
try {
|
||||
worker.schedule(() -> {
|
||||
scanQueued.set(false);
|
||||
nextScan = 0;
|
||||
scanSafely();
|
||||
}, 3, TimeUnit.SECONDS);
|
||||
} catch (RejectedExecutionException e) {
|
||||
scanQueued.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
WebSocket ws = socket;
|
||||
if (ws != null) ws.abort();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
|
||||
// ---- chunks ----
|
||||
|
||||
private void scanSafely() {
|
||||
if (closed || System.nanoTime() < nextScan) return;
|
||||
try {
|
||||
if (!refreshEnabled()) return;
|
||||
long uploaded = scan();
|
||||
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403) panelEnabled = false;
|
||||
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
|
||||
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
|
||||
private boolean refreshEnabled() throws IOException, InterruptedException {
|
||||
if (panelEnabled) return true;
|
||||
long now = System.nanoTime();
|
||||
if (now < configCheckAt) return false;
|
||||
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
|
||||
try {
|
||||
JsonObject config = client.get("livemap/config");
|
||||
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
|
||||
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
|
||||
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
panelEnabled = false; // an older panel without live map support
|
||||
}
|
||||
return panelEnabled;
|
||||
}
|
||||
|
||||
/** Returns how many bytes of chunk data were uploaded. */
|
||||
private long scan() throws IOException, InterruptedException {
|
||||
if (!Files.isDirectory(root)) {
|
||||
if (!warnedMissing) {
|
||||
warnedMissing = true;
|
||||
log.accept("SCOPENET live map: world folder " + root + " not found");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
uploadLevelIfChanged();
|
||||
long total = 0;
|
||||
for (String dim : DIMS) {
|
||||
Path dir = regionDir(dim);
|
||||
if (dir == null) continue;
|
||||
loadManifest(dim);
|
||||
Batch batch = new Batch(dim);
|
||||
List<Path> files = new ArrayList<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
|
||||
stream.forEach(files::add);
|
||||
}
|
||||
files.sort(Comparator.comparing(Path::toString));
|
||||
for (Path file : files) {
|
||||
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
|
||||
total += readRegion(dim, file, batch);
|
||||
}
|
||||
total += batch.flush();
|
||||
if (total >= SCAN_BUDGET_BYTES) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private Path regionDir(String dim) {
|
||||
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
|
||||
Path parent = root.toAbsolutePath().getParent();
|
||||
List<Path> candidates = switch (dim) {
|
||||
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
|
||||
root.resolve("dimensions/minecraft/the_nether/region"));
|
||||
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
|
||||
root.resolve("dimensions/minecraft/the_end/region"));
|
||||
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
|
||||
};
|
||||
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Path sibling(Path parent, String name, String rel) {
|
||||
return parent == null ? null : parent.resolve(name).resolve(rel);
|
||||
}
|
||||
|
||||
private void loadManifest(String dim) throws IOException, InterruptedException {
|
||||
if (manifestLoaded.contains(dim)) return;
|
||||
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
|
||||
for (JsonElement el : manifest.getAsJsonArray("regions")) {
|
||||
JsonObject region = el.getAsJsonObject();
|
||||
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
|
||||
int[] ts = new int[1024];
|
||||
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
|
||||
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
|
||||
}
|
||||
manifestLoaded.add(dim);
|
||||
}
|
||||
|
||||
private static String key(String dim, int rx, int rz) {
|
||||
return dim + ":" + rx + ":" + rz;
|
||||
}
|
||||
|
||||
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
|
||||
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
|
||||
String[] parts = file.getFileName().toString().split("\\.");
|
||||
int rx, rz;
|
||||
try {
|
||||
rx = Integer.parseInt(parts[1]);
|
||||
rz = Integer.parseInt(parts[2]);
|
||||
} catch (RuntimeException e) {
|
||||
return 0;
|
||||
}
|
||||
long flushed = 0;
|
||||
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
|
||||
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
|
||||
long size = ch.size();
|
||||
if (size < 2L * SECTOR) return 0;
|
||||
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
|
||||
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
|
||||
for (int i = 0; i < 1024; i++) {
|
||||
int loc = header.getInt(i * 4);
|
||||
int ts = header.getInt(SECTOR + i * 4);
|
||||
int count = loc & 0xff;
|
||||
long offset = (loc >>> 8) & 0xffffffL;
|
||||
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
|
||||
if (offset < 2 || (offset + count) * SECTOR > size) continue;
|
||||
ByteBuffer prefix = ByteBuffer.allocate(5);
|
||||
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
|
||||
if (prefix.hasRemaining()) continue;
|
||||
int length = prefix.getInt(0);
|
||||
int compression = prefix.get(4) & 0xff;
|
||||
// Oversized chunks live in external .mcc files; skip those and anything malformed.
|
||||
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
|
||||
ByteBuffer data = ByteBuffer.allocate(length - 1);
|
||||
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
|
||||
if (data.hasRemaining()) continue;
|
||||
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
|
||||
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
|
||||
}
|
||||
}
|
||||
return flushed;
|
||||
}
|
||||
|
||||
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
|
||||
private final class Batch {
|
||||
final String dim;
|
||||
final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
final List<int[]> marks = new ArrayList<>();
|
||||
int size;
|
||||
|
||||
Batch(String dim) {
|
||||
this.dim = dim;
|
||||
}
|
||||
|
||||
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
|
||||
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
|
||||
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
|
||||
out.write(rec.array(), 0, rec.capacity());
|
||||
marks.add(new int[]{rx, rz, index, ts});
|
||||
size += rec.capacity();
|
||||
}
|
||||
|
||||
long flush() throws IOException, InterruptedException {
|
||||
if (marks.isEmpty()) return 0;
|
||||
long sent = size;
|
||||
try {
|
||||
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
|
||||
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
|
||||
} finally {
|
||||
out.reset();
|
||||
marks.clear();
|
||||
size = 0;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
}
|
||||
|
||||
private void uploadLevelIfChanged() throws IOException, InterruptedException {
|
||||
Path level = root.resolve("level.dat");
|
||||
if (!Files.isRegularFile(level)) return;
|
||||
long modified = Files.getLastModifiedTime(level).toMillis();
|
||||
if (modified == levelModified) return;
|
||||
byte[] bytes = Files.readAllBytes(level);
|
||||
if (bytes.length > 2 * 1024 * 1024) return;
|
||||
client.postBytes("livemap/level", bytes);
|
||||
levelModified = modified;
|
||||
}
|
||||
|
||||
// ---- players ----
|
||||
|
||||
private void sendPlayersSafely() {
|
||||
if (closed || !panelEnabled) return;
|
||||
long now = System.nanoTime();
|
||||
// Resend unchanged rosters now and then so the panel knows we're alive.
|
||||
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
|
||||
if (!playersChanged && !heartbeat) return;
|
||||
playersChanged = false;
|
||||
lastPlayersSent = now;
|
||||
JsonArray list = new JsonArray();
|
||||
for (Player p : players) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("uuid", p.uuid());
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("dimension", p.dimension());
|
||||
o.addProperty("x", p.x());
|
||||
o.addProperty("y", p.y());
|
||||
o.addProperty("z", p.z());
|
||||
o.addProperty("yaw", p.yaw());
|
||||
o.addProperty("pitch", p.pitch());
|
||||
list.add(o);
|
||||
}
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("type", "players");
|
||||
body.add("players", list);
|
||||
try {
|
||||
WebSocket ws = socket;
|
||||
if (ws != null && !ws.isOutputClosed()) {
|
||||
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
|
||||
return;
|
||||
}
|
||||
connectSocket();
|
||||
client.post("livemap/players", body);
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403) panelEnabled = false;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
socket = null; // reconnect later; HTTPS covers the gap
|
||||
}
|
||||
}
|
||||
|
||||
private void connectSocket() {
|
||||
long now = System.nanoTime();
|
||||
if (now < socketRetryAt) return;
|
||||
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
|
||||
try {
|
||||
Settings settings = client.settings();
|
||||
URI panel = settings.panel();
|
||||
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
|
||||
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
|
||||
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
|
||||
client.http().newWebSocketBuilder()
|
||||
.header("Authorization", "Bearer " + settings.token())
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.buildAsync(uri, new WebSocket.Listener() {
|
||||
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
|
||||
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
|
||||
ws.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
|
||||
if (socket == ws) socket = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override public void onError(WebSocket ws, Throwable error) {
|
||||
if (socket == ws) socket = null;
|
||||
}
|
||||
}).thenAccept(ws -> {
|
||||
if (closed) ws.abort();
|
||||
else socket = ws;
|
||||
});
|
||||
} catch (RuntimeException ignored) {
|
||||
// The HTTPS fallback keeps working.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,12 +107,12 @@ public record Settings(
|
||||
);
|
||||
}
|
||||
|
||||
/** Local opt-out for the central live map (the panel also has a per-server switch). */
|
||||
public static boolean liveMapEnabled(Path path) {
|
||||
/** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
|
||||
public static boolean mapEnabled(Path path) {
|
||||
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
Properties properties = new Properties();
|
||||
properties.load(reader);
|
||||
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
|
||||
return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true")));
|
||||
} catch (IOException e) {
|
||||
return true;
|
||||
}
|
||||
@@ -139,9 +139,33 @@ public record Settings(
|
||||
essentials.enabled=true
|
||||
economy.enabled=true
|
||||
|
||||
# Send world chunks and player positions to the panel's central Live Map.
|
||||
# It only runs when Live Map is also enabled for this server in the panel.
|
||||
livemap.enabled=true
|
||||
# Essentials limits. Players can exceed max_homes with the permission scopenet.homes.<number>
|
||||
# (or scopenet.homes.unlimited). Cooldowns are seconds, 0 = none; scopenet.cooldown.bypass skips them.
|
||||
essentials.max_homes=5
|
||||
essentials.home_name_max_length=32
|
||||
essentials.max_warps=0
|
||||
essentials.tpa_timeout_seconds=60
|
||||
essentials.rtp_radius=2500
|
||||
essentials.rtp_min_radius=0
|
||||
essentials.rtp_attempts=15
|
||||
essentials.cooldown.spawn=0
|
||||
essentials.cooldown.home=0
|
||||
essentials.cooldown.back=0
|
||||
essentials.cooldown.tpa=0
|
||||
essentials.cooldown.rtp=0
|
||||
essentials.cooldown.warp=0
|
||||
|
||||
# Rewards from quests, achievements and rank milestones (items, permissions, groups, messages).
|
||||
# allow_commands lets the panel run its own console commands as rewards: leave off unless you trust every panel admin.
|
||||
rewards.enabled=true
|
||||
rewards.allow_commands=false
|
||||
rewards.permission_command=lp user {player} permission set {node} true
|
||||
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
|
||||
rewards.group_command=lp user {player} parent add {group}
|
||||
|
||||
# Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
|
||||
# It only runs when the map is also enabled for this server in the panel.
|
||||
map.enabled=true
|
||||
""";
|
||||
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static net.scopenet.worldmap.MapColors.*;
|
||||
|
||||
/**
|
||||
* Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by
|
||||
* name keywords, then falls back to a stable neutral colour so the same block always looks the same.
|
||||
*/
|
||||
public final class BlockColors {
|
||||
private BlockColors() {}
|
||||
|
||||
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Set<String> CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane",
|
||||
"tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot",
|
||||
"moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air");
|
||||
private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"};
|
||||
private static final Set<String> WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass");
|
||||
|
||||
private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan",
|
||||
"purple", "blue", "brown", "green", "red", "black"};
|
||||
private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE,
|
||||
BROWN, GREEN, RED, BLACK};
|
||||
private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51};
|
||||
private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"};
|
||||
|
||||
private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_",
|
||||
"infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"};
|
||||
private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign",
|
||||
"_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"};
|
||||
|
||||
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
|
||||
|
||||
/** Water is special-cased by the renderer, which shades it by depth. */
|
||||
public static final int WATER_COLOR = 0xFF3F76E4;
|
||||
|
||||
private static final Map<String, Integer> TEXTURES = loadTextures();
|
||||
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
|
||||
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
|
||||
*/
|
||||
public static int of(String blockName) {
|
||||
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
|
||||
}
|
||||
|
||||
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
|
||||
public static int mapColor(String blockName) {
|
||||
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
|
||||
}
|
||||
|
||||
private static int resolve(String full) {
|
||||
int id = mapColor(full);
|
||||
if (id == 0) return 0;
|
||||
if (id == WATER) return WATER_COLOR;
|
||||
String n = plain(full);
|
||||
Integer exact = TEXTURES.get(n);
|
||||
if (exact != null) return exact;
|
||||
String s = n;
|
||||
for (boolean changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
|
||||
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
|
||||
if (!changed) break;
|
||||
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
|
||||
Integer hit = TEXTURES.get(candidate);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return MapColors.base(id);
|
||||
}
|
||||
|
||||
private static String plain(String full) {
|
||||
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static Map<String, Integer> loadTextures() {
|
||||
Map<String, Integer> out = new java.util.HashMap<>();
|
||||
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
|
||||
if (in == null) return out;
|
||||
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
|
||||
int comma = line.indexOf(',');
|
||||
if (comma <= 0 || comma + 7 > line.length()) continue;
|
||||
try {
|
||||
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// a malformed row just falls back to the map colour table
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
// no texture table: the map colour table covers everything
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int classify(String full) {
|
||||
String n = plain(full);
|
||||
int c = lookup(n);
|
||||
if (c >= 0) return c;
|
||||
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
|
||||
String s = n;
|
||||
for (boolean changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
|
||||
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
|
||||
if (changed && (c = lookup(s)) >= 0) return c;
|
||||
}
|
||||
return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)];
|
||||
}
|
||||
|
||||
/** The colour for a plain name, or -1 when nothing matches. */
|
||||
private static int lookup(String n) {
|
||||
if (CLEAR.contains(n)) return NONE;
|
||||
for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE;
|
||||
if (WATERY.contains(n)) return WATER;
|
||||
if (n.equals("lava")) return FIRE;
|
||||
for (int i = 0; i < DYES.length; i++) {
|
||||
String d = DYES[i] + "_";
|
||||
if (!n.startsWith(d)) continue;
|
||||
String rest = n.substring(d.length());
|
||||
if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i];
|
||||
for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i];
|
||||
}
|
||||
if (n.equals("terracotta")) return ORANGE;
|
||||
if (n.equals("grass_block") || n.equals("slime_block")) return GRASS;
|
||||
if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE
|
||||
: n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1;
|
||||
if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE
|
||||
: n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1;
|
||||
if (n.contains("deepslate")) return DEEPSLATE;
|
||||
if (n.contains("tuff")) return TERRACOTTA_GRAY;
|
||||
if (n.contains("calcite")) return TERRACOTTA_WHITE;
|
||||
if (n.contains("dripstone")) return TERRACOTTA_BROWN;
|
||||
if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK;
|
||||
if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER;
|
||||
if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED;
|
||||
if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN;
|
||||
if (n.equals("glowstone")) return SAND;
|
||||
if (n.equals("bedrock")) return STONE;
|
||||
if (n.contains("red_sand")) return ORANGE;
|
||||
if (n.contains("end_stone")) return SAND;
|
||||
if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND;
|
||||
if (n.contains("gravel")) return STONE;
|
||||
if (n.contains("podzol")) return PODZOL;
|
||||
if (n.contains("mycelium")) return PURPLE;
|
||||
if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN;
|
||||
if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT;
|
||||
if (n.equals("clay")) return CLAY;
|
||||
if (n.contains("snow")) return SNOW;
|
||||
if (n.equals("ice") || n.endsWith("_ice")) return ICE;
|
||||
if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM
|
||||
: n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE;
|
||||
if (n.equals("raw_iron_block")) return RAW_IRON;
|
||||
if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD;
|
||||
if (n.equals("raw_copper_block")) return ORANGE;
|
||||
if (n.equals("diamond_block")) return DIAMOND;
|
||||
if (n.equals("emerald_block")) return EMERALD;
|
||||
if (n.equals("lapis_block")) return LAPIS;
|
||||
if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE;
|
||||
if (n.contains("amethyst")) return PURPLE;
|
||||
if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ;
|
||||
if (n.contains("andesite")) return STONE;
|
||||
if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA;
|
||||
if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND;
|
||||
if (n.contains("prismarine")) return CYAN;
|
||||
if (n.contains("_ore")) return STONE;
|
||||
if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron")
|
||||
|| n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL;
|
||||
if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer")
|
||||
|| n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE;
|
||||
if (n.equals("hay_block")) return YELLOW;
|
||||
if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW;
|
||||
if (n.contains("melon")) return LIGHT_GREEN;
|
||||
if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE;
|
||||
if (n.equals("glow_lichen")) return GLOW_LICHEN;
|
||||
if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN;
|
||||
if (n.contains("red_mushroom")) return RED;
|
||||
if (n.contains("brown_mushroom")) return DIRT;
|
||||
if (n.equals("mushroom_stem")) return WOOL;
|
||||
if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK;
|
||||
if (n.equals("beacon")) return DIAMOND;
|
||||
if (n.contains("cherry_leaves")) return PINK;
|
||||
if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea")
|
||||
|| n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip")
|
||||
|| n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet")
|
||||
|| n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony")
|
||||
|| n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes")
|
||||
|| n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush")
|
||||
|| n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT;
|
||||
if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD;
|
||||
if (n.contains("mangrove")) return RED;
|
||||
if (n.contains("spruce")) return PODZOL;
|
||||
if (n.contains("birch")) return SAND;
|
||||
if (n.contains("jungle")) return DIRT;
|
||||
if (n.contains("acacia")) return ORANGE;
|
||||
if (n.contains("cherry")) return TERRACOTTA_WHITE;
|
||||
if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest")
|
||||
|| n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom")
|
||||
|| n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD;
|
||||
if (n.contains("coal")) return BLACK;
|
||||
if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL;
|
||||
if (n.contains("wool") || n.contains("carpet")) return WOOL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */
|
||||
public final class ChunkSampler {
|
||||
private ChunkSampler() {}
|
||||
|
||||
public enum Kind { OVERWORLD, NETHER, END }
|
||||
|
||||
/** Per-column results, indexed {@code z * 16 + x}. */
|
||||
public static final class Columns {
|
||||
public final int[] color = new int[256];
|
||||
public final int[] height = new int[256];
|
||||
/** For water: how many blocks of water sit above the floor. */
|
||||
public final int[] depth = new int[256];
|
||||
}
|
||||
|
||||
private record Section(int y, int[] colors, long[] data, int bits) {
|
||||
int colorAt(int x, int y, int z) {
|
||||
if (data == null) return colors[0];
|
||||
int index = y << 8 | z << 4 | x;
|
||||
int perLong = 64 / bits;
|
||||
int word = index / perLong;
|
||||
if (word >= data.length) return 0;
|
||||
int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1));
|
||||
return v < colors.length ? colors[v] : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the chunk was fully generated and could be read (the columns are then filled in). */
|
||||
public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) {
|
||||
String status = chunk.string("Status", "minecraft:full");
|
||||
if (!status.endsWith("full")) return false;
|
||||
List<Section> sections = new ArrayList<>();
|
||||
for (Object o : chunk.list("sections")) {
|
||||
if (!(o instanceof Nbt.Compound s)) continue;
|
||||
Nbt.Compound states = s.compound("block_states");
|
||||
if (states == null) continue;
|
||||
List<Object> palette = states.list("palette");
|
||||
if (palette.isEmpty()) continue;
|
||||
int[] colors = new int[palette.size()];
|
||||
for (int i = 0; i < colors.length; i++) {
|
||||
colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0;
|
||||
}
|
||||
long[] data = states.longs("data");
|
||||
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1));
|
||||
sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits));
|
||||
}
|
||||
if (sections.isEmpty()) return false;
|
||||
sections.sort((a, b) -> Integer.compare(b.y(), a.y()));
|
||||
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
int i = z * 16 + x;
|
||||
out.color[i] = 0;
|
||||
out.height[i] = 0;
|
||||
out.depth[i] = 0;
|
||||
if (kind == Kind.NETHER) column(sections, x, z, out, i, true);
|
||||
else column(sections, x, z, out, i, false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void column(List<Section> sections, int x, int z, Columns out, int i, boolean roofed) {
|
||||
boolean pastRoof = !roofed;
|
||||
int roofColor = 0, roofY = 0;
|
||||
for (int si = 0; si < sections.size(); si++) {
|
||||
Section sec = sections.get(si);
|
||||
for (int ly = 15; ly >= 0; ly--) {
|
||||
int worldY = sec.y() * 16 + ly;
|
||||
if (roofed && worldY > 126) continue;
|
||||
int c = sec.colorAt(x, ly, z);
|
||||
if (!pastRoof) {
|
||||
// Under the nether ceiling: skip solid rock until the first open air.
|
||||
if (c == 0) pastRoof = true;
|
||||
else if (roofColor == 0) { roofColor = c; roofY = worldY; }
|
||||
continue;
|
||||
}
|
||||
if (c == 0) continue;
|
||||
out.color[i] = c;
|
||||
out.height[i] = worldY;
|
||||
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (roofed && roofColor != 0) { // a solid column: show the ceiling instead
|
||||
out.color[i] = roofColor;
|
||||
out.height[i] = roofY;
|
||||
}
|
||||
}
|
||||
|
||||
private static int waterDepth(List<Section> sections, int si, int ly, int x, int z) {
|
||||
int depth = 1;
|
||||
for (int s = si; s < sections.size(); s++) {
|
||||
Section sec = sections.get(s);
|
||||
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
|
||||
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
|
||||
if (++depth >= 64) return depth;
|
||||
}
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */
|
||||
public final class MapColors {
|
||||
private MapColors() {}
|
||||
|
||||
public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10,
|
||||
STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19,
|
||||
PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29,
|
||||
GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37,
|
||||
TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52,
|
||||
CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58,
|
||||
DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61;
|
||||
|
||||
private static final int[] RGB = {
|
||||
0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070,
|
||||
0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99,
|
||||
0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200,
|
||||
0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C,
|
||||
0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464,
|
||||
0xD8AF93, 0x7FA796};
|
||||
|
||||
public static final int COUNT = RGB.length;
|
||||
|
||||
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
|
||||
private static final int[] SHADE = {180, 220, 255, 135};
|
||||
|
||||
/** The colour's base RGB (opaque ARGB), or 0 for none. */
|
||||
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
|
||||
|
||||
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
|
||||
public static int argb(int id, int shade) {
|
||||
if (id <= 0 || id >= COUNT) return 0;
|
||||
int base = RGB[id], m = SHADE[shade & 3];
|
||||
int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255;
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */
|
||||
public final class Nbt {
|
||||
private Nbt() {}
|
||||
|
||||
/** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */
|
||||
public static final class Compound extends HashMap<String, Object> {
|
||||
public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; }
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Object> list(String key) { return get(key) instanceof List<?> l ? (List<Object>) l : List.of(); }
|
||||
|
||||
public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; }
|
||||
|
||||
public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; }
|
||||
|
||||
public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; }
|
||||
}
|
||||
|
||||
private static final int MAX_DEPTH = 64;
|
||||
private static final int MAX_ARRAY = 8 * 1024 * 1024;
|
||||
|
||||
/** Reads one named root compound (the form chunks are stored in). */
|
||||
public static Compound readRoot(DataInputStream in) throws IOException {
|
||||
byte type = in.readByte();
|
||||
if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type);
|
||||
in.readUTF(); // root name, usually empty
|
||||
return readCompound(in, 0);
|
||||
}
|
||||
|
||||
private static Compound readCompound(DataInputStream in, int depth) throws IOException {
|
||||
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
|
||||
Compound out = new Compound();
|
||||
while (true) {
|
||||
byte type = in.readByte();
|
||||
if (type == 0) return out;
|
||||
String name = in.readUTF();
|
||||
out.put(name, readValue(in, type, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static Object readValue(DataInputStream in, byte type, int depth) throws IOException {
|
||||
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
|
||||
switch (type) {
|
||||
case 1: return in.readByte();
|
||||
case 2: return in.readShort();
|
||||
case 3: return in.readInt();
|
||||
case 4: return in.readLong();
|
||||
case 5: return in.readFloat();
|
||||
case 6: return in.readDouble();
|
||||
case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; }
|
||||
case 8: return in.readUTF();
|
||||
case 9: {
|
||||
byte elementType = in.readByte();
|
||||
int n = length(in);
|
||||
List<Object> list = new ArrayList<>(Math.min(n, 4096));
|
||||
for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1));
|
||||
return list;
|
||||
}
|
||||
case 10: return readCompound(in, depth);
|
||||
case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; }
|
||||
case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; }
|
||||
default: throw new IOException("unknown NBT tag " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private static int length(DataInputStream in) throws IOException {
|
||||
int n = in.readInt();
|
||||
if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.Deflater;
|
||||
|
||||
/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */
|
||||
public final class Png {
|
||||
private Png() {}
|
||||
|
||||
/**
|
||||
* Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth
|
||||
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
|
||||
*/
|
||||
public static byte[] encode(int[] argb, int width, int height) {
|
||||
argb = limitColors(argb);
|
||||
Map<Integer, Integer> palette = new LinkedHashMap<>();
|
||||
for (int c : argb) {
|
||||
int key = (c >>> 24) == 0 ? 0 : c;
|
||||
if (!palette.containsKey(key)) {
|
||||
palette.put(key, palette.size());
|
||||
if (palette.size() > 256) return rgba(argb, width, height);
|
||||
}
|
||||
}
|
||||
int n = palette.size();
|
||||
int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8;
|
||||
int rowBytes = (width * depth + 7) / 8;
|
||||
byte[] raw = new byte[height * (1 + rowBytes)];
|
||||
int p = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
raw[p++] = 0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int c = argb[y * width + x];
|
||||
int idx = palette.get((c >>> 24) == 0 ? 0 : c);
|
||||
if (depth == 8) raw[p + x] = (byte) idx;
|
||||
else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8));
|
||||
}
|
||||
p += rowBytes;
|
||||
}
|
||||
byte[] plte = new byte[n * 3];
|
||||
List<Integer> alpha = new ArrayList<>();
|
||||
for (var e : palette.entrySet()) {
|
||||
int c = e.getKey(), i = e.getValue();
|
||||
plte[i * 3] = (byte) (c >> 16);
|
||||
plte[i * 3 + 1] = (byte) (c >> 8);
|
||||
plte[i * 3 + 2] = (byte) c;
|
||||
alpha.add(c == 0 ? 0 : c >>> 24);
|
||||
}
|
||||
int last = -1;
|
||||
for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i;
|
||||
byte[] trns = new byte[last + 1];
|
||||
for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
|
||||
out.writeBytes(SIGNATURE);
|
||||
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array());
|
||||
chunk(out, "PLTE", plte);
|
||||
if (trns.length > 0) chunk(out, "tRNS", trns);
|
||||
chunk(out, "IDAT", deflate(raw));
|
||||
chunk(out, "IEND", new byte[0]);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
|
||||
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
|
||||
*/
|
||||
static int[] limitColors(int[] argb) {
|
||||
Map<Integer, Integer> counts = new HashMap<>();
|
||||
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
|
||||
if (counts.size() <= 255) return argb;
|
||||
List<Integer> keep = new ArrayList<>(counts.keySet());
|
||||
keep.sort((a, b) -> counts.get(b) - counts.get(a));
|
||||
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
|
||||
Set<Integer> kept = new HashSet<>(top);
|
||||
Map<Integer, Integer> remap = new HashMap<>();
|
||||
int[] out = argb.clone();
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int c = out[i];
|
||||
if ((c >>> 24) == 0 || kept.contains(c)) continue;
|
||||
out[i] = remap.computeIfAbsent(c, k -> {
|
||||
int best = top.get(0), bestD = Integer.MAX_VALUE;
|
||||
for (int q : top) {
|
||||
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
|
||||
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
|
||||
private static byte[] deflate(byte[] raw) {
|
||||
Deflater deflater = new Deflater(9);
|
||||
deflater.setInput(raw);
|
||||
deflater.finish();
|
||||
ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64);
|
||||
byte[] buf = new byte[16384];
|
||||
while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf));
|
||||
deflater.end();
|
||||
return z.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] rgba(int[] argb, int width, int height) {
|
||||
byte[] raw = new byte[height * (1 + width * 4)];
|
||||
int p = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
raw[p++] = 0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int c = argb[y * width + x];
|
||||
raw[p++] = (byte) (c >> 16);
|
||||
raw[p++] = (byte) (c >> 8);
|
||||
raw[p++] = (byte) c;
|
||||
raw[p++] = (byte) (c >>> 24);
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
|
||||
out.writeBytes(SIGNATURE);
|
||||
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array());
|
||||
chunk(out, "IDAT", deflate(raw));
|
||||
chunk(out, "IEND", new byte[0]);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void chunk(ByteArrayOutputStream out, String type, byte[] data) {
|
||||
out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array());
|
||||
byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
out.writeBytes(t);
|
||||
out.writeBytes(data);
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(t);
|
||||
crc.update(data);
|
||||
out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
/**
|
||||
* Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running:
|
||||
* a chunk caught mid-write simply fails to read and is retried on the next pass.
|
||||
*/
|
||||
public final class RegionFile implements AutoCloseable {
|
||||
private static final int SECTOR = 4096;
|
||||
|
||||
private final FileChannel channel;
|
||||
private final int[] locations = new int[1024];
|
||||
private final int[] timestamps = new int[1024];
|
||||
|
||||
public RegionFile(Path file) throws IOException {
|
||||
channel = FileChannel.open(file, StandardOpenOption.READ);
|
||||
try {
|
||||
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
|
||||
while (header.hasRemaining()) if (channel.read(header) < 0) break;
|
||||
header.flip();
|
||||
if (header.remaining() == 2 * SECTOR) {
|
||||
header.asIntBuffer().get(locations, 0, 1024);
|
||||
header.position(SECTOR);
|
||||
header.asIntBuffer().get(timestamps, 0, 1024);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
channel.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; }
|
||||
|
||||
/** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */
|
||||
public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; }
|
||||
|
||||
/**
|
||||
* The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read.
|
||||
* Region files written with LZ4 (a server option since 1.20.5) are skipped.
|
||||
*/
|
||||
public Nbt.Compound read(int index) {
|
||||
int loc = locations[index];
|
||||
if (loc == 0) return null;
|
||||
long offset = (long) (loc >>> 8) * SECTOR;
|
||||
int sectors = loc & 0xFF;
|
||||
if (offset < 2 * SECTOR || sectors == 0) return null;
|
||||
try {
|
||||
ByteBuffer head = ByteBuffer.allocate(5);
|
||||
if (read(head, offset) < 5) return null;
|
||||
head.flip();
|
||||
int length = head.getInt();
|
||||
int type = head.get() & 0xFF;
|
||||
if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file
|
||||
ByteBuffer body = ByteBuffer.allocate(length - 1);
|
||||
if (read(body, offset + 5) < length - 1) return null;
|
||||
InputStream raw = new ByteArrayInputStream(body.array());
|
||||
InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw;
|
||||
try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) {
|
||||
return Nbt.readRoot(in);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int read(ByteBuffer buffer, long position) throws IOException {
|
||||
int total = 0;
|
||||
while (buffer.hasRemaining()) {
|
||||
int n = channel.read(buffer, position + total);
|
||||
if (n < 0) break;
|
||||
total += n;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@Override public void close() throws IOException { channel.close(); }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded
|
||||
* by the slope towards the north, and water shaded by depth.
|
||||
*/
|
||||
public final class RegionRenderer {
|
||||
private RegionRenderer() {}
|
||||
|
||||
public static final int TILE = 256;
|
||||
private static final int SIDE = 512;
|
||||
|
||||
/** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */
|
||||
public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException {
|
||||
int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE];
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
boolean any = false;
|
||||
try (RegionFile region = new RegionFile(file)) {
|
||||
for (int i = 0; i < 1024; i++) {
|
||||
if (region.timestamp(i) == 0) continue;
|
||||
Nbt.Compound chunk = region.read(i);
|
||||
if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue;
|
||||
int cx = (i & 31) * 16, cz = (i >> 5) * 16;
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int dst = (cz + z) * SIDE + cx;
|
||||
System.arraycopy(cols.color, z * 16, colors, dst, 16);
|
||||
System.arraycopy(cols.height, z * 16, heights, dst, 16);
|
||||
System.arraycopy(cols.depth, z * 16, depths, dst, 16);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) return null;
|
||||
int[] pixels = shade(colors, heights, depths);
|
||||
int[][] tiles = new int[4][TILE * TILE];
|
||||
for (int ty = 0; ty < 2; ty++) {
|
||||
for (int tx = 0; tx < 2; tx++) {
|
||||
int[] tile = tiles[tx + ty * 2];
|
||||
for (int row = 0; row < TILE; row++) {
|
||||
System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
|
||||
* Colours are the blocks' own, so they stay in few steps and compress well.
|
||||
*/
|
||||
static int[] shade(int[] colors, int[] heights, int[] depths) {
|
||||
int[] out = new int[SIDE * SIDE];
|
||||
for (int z = 0; z < SIDE; z++) {
|
||||
for (int x = 0; x < SIDE; x++) {
|
||||
int i = z * SIDE + x;
|
||||
int c = colors[i];
|
||||
if (c == 0) continue;
|
||||
int percent;
|
||||
if (c == BlockColors.WATER_COLOR) {
|
||||
percent = 108 - Math.min(depths[i], 14) * 4;
|
||||
} else {
|
||||
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
|
||||
int diff = heights[i] - north;
|
||||
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
|
||||
}
|
||||
out[i] = scale(c, percent);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int scale(int argb, int percent) {
|
||||
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
|
||||
b = Math.min(255, (argb & 0xFF) * percent / 100);
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.*;
|
||||
|
||||
/**
|
||||
* The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per
|
||||
* block; every level up halves the resolution, down to {@code maxZoom}.
|
||||
*/
|
||||
public final class TileStore {
|
||||
public record Key(String dim, int zoom, int x, int y) {
|
||||
@Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; }
|
||||
|
||||
public static Key parse(String s) {
|
||||
String[] p = s.split("/");
|
||||
return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]));
|
||||
}
|
||||
}
|
||||
|
||||
private static final int SIZE = RegionRenderer.TILE;
|
||||
private static final int PIXELS = SIZE * SIZE;
|
||||
|
||||
private final Path root;
|
||||
private final Map<Key, int[]> memory = new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override protected boolean removeEldestEntry(Map.Entry<Key, int[]> e) { return size() > 96; }
|
||||
};
|
||||
|
||||
public TileStore(Path root) { this.root = root; }
|
||||
|
||||
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
|
||||
|
||||
/** The tile's pixels, or null if it was never rendered. */
|
||||
public synchronized int[] load(Key k) {
|
||||
int[] cached = memory.get(k);
|
||||
if (cached != null) return cached;
|
||||
Path f = file(k);
|
||||
if (!Files.isRegularFile(f)) return null;
|
||||
try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
|
||||
byte[] raw = in.readAllBytes();
|
||||
if (raw.length != PIXELS * 4) return null;
|
||||
int[] pixels = new int[PIXELS];
|
||||
ByteBuffer.wrap(raw).asIntBuffer().get(pixels);
|
||||
memory.put(k, pixels);
|
||||
return pixels;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void save(Key k, int[] pixels) throws IOException {
|
||||
ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4);
|
||||
raw.asIntBuffer().put(pixels);
|
||||
Path f = file(k);
|
||||
Files.createDirectories(f.getParent());
|
||||
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
|
||||
try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) {
|
||||
out.write(raw.array());
|
||||
}
|
||||
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
|
||||
memory.put(k, pixels);
|
||||
}
|
||||
|
||||
public synchronized void clear() throws IOException {
|
||||
memory.clear();
|
||||
if (!Files.isDirectory(root)) return;
|
||||
try (var walk = Files.walk(root)) {
|
||||
for (Path p : (Iterable<Path>) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean blank(int[] pixels) {
|
||||
for (int p : pixels) if ((p >>> 24) != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stores a freshly rendered tile and returns true if it differs from what was there. */
|
||||
public boolean update(Key k, int[] pixels) throws IOException {
|
||||
int[] old = load(k);
|
||||
if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false;
|
||||
save(k, pixels);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null.
|
||||
*/
|
||||
public Key rebuildParent(Key child) throws IOException {
|
||||
int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1;
|
||||
int[] out = new int[PIXELS];
|
||||
for (int qy = 0; qy < 2; qy++) {
|
||||
for (int qx = 0; qx < 2; qx++) {
|
||||
int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy));
|
||||
if (src == null) continue;
|
||||
int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2);
|
||||
for (int y = 0; y < SIZE / 2; y++) {
|
||||
for (int x = 0; x < SIZE / 2; x++) {
|
||||
out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1],
|
||||
src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Key parent = new Key(child.dim(), pz, px, py);
|
||||
return update(parent, out) ? parent : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps
|
||||
* zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well.
|
||||
*/
|
||||
static int average(int a, int b, int c, int d) {
|
||||
int[] px = {a, b, c, d};
|
||||
int best = 0, bestCount = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if ((px[i] >>> 24) == 0) continue;
|
||||
int count = 0;
|
||||
for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++;
|
||||
if (count > bestCount) { best = px[i]; bestCount = count; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import com.google.gson.*;
|
||||
import net.scopenet.integration.PanelClient;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one
|
||||
* region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along.
|
||||
* Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}.
|
||||
*/
|
||||
public final class WorldMapSync implements AutoCloseable {
|
||||
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
|
||||
|
||||
private record Dim(String id, String dir, ChunkSampler.Kind kind) {}
|
||||
|
||||
private static final Dim[] DIMS = {
|
||||
new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD),
|
||||
new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER),
|
||||
new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)};
|
||||
private static final int BATCH_BYTES = 3 * 1024 * 1024;
|
||||
private static final long RERENDER_GAP_MS = 90_000;
|
||||
private static final long COARSE_UPLOAD_GAP_MS = 60_000;
|
||||
|
||||
private static final class State {
|
||||
long epoch = -1;
|
||||
/** "dim/x.z" -> file mtime (ms) at the time it was rendered. */
|
||||
Map<String, Long> regions = new HashMap<>();
|
||||
/** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */
|
||||
Set<String> dirty = new HashSet<>();
|
||||
}
|
||||
|
||||
private final PanelClient client;
|
||||
private final Path worldRoot;
|
||||
private final Path cacheDir;
|
||||
private final Consumer<String> log;
|
||||
private final Gson gson = new Gson();
|
||||
private final TileStore store;
|
||||
private final Object wake = new Object();
|
||||
private final Thread thread;
|
||||
private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "scopenet-map-live");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private State state = new State();
|
||||
private final Map<String, Long> renderedAt = new HashMap<>();
|
||||
private volatile boolean closed;
|
||||
private volatile boolean enabled;
|
||||
private volatile boolean wakeRequested;
|
||||
private volatile int maxZoom = 6;
|
||||
private volatile long playerIntervalMs = 2000;
|
||||
private volatile List<Player> players = List.of();
|
||||
private volatile java.util.function.Supplier<String> overlay;
|
||||
private long configAt;
|
||||
private long coarseUploadAt;
|
||||
private boolean stateDirty;
|
||||
private boolean warnedMissing;
|
||||
private String lastOverlaySent;
|
||||
private long overlaySentAt;
|
||||
private String lastPlayersSent = "";
|
||||
private long playersSentAt;
|
||||
|
||||
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
|
||||
this.client = client;
|
||||
this.worldRoot = worldRoot;
|
||||
this.cacheDir = cacheDir;
|
||||
this.log = log;
|
||||
this.store = new TileStore(cacheDir.resolve("tiles"));
|
||||
thread = new Thread(this::run, "scopenet-map");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
loadState();
|
||||
thread.start();
|
||||
live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void offerPlayers(List<Player> next) {
|
||||
players = List.copyOf(next);
|
||||
}
|
||||
|
||||
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
|
||||
public void setOverlay(java.util.function.Supplier<String> source) {
|
||||
overlay = source;
|
||||
}
|
||||
|
||||
/** The world was saved: look for changed regions soon. */
|
||||
public void requestScan() {
|
||||
synchronized (wake) {
|
||||
wakeRequested = true;
|
||||
wake.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
live.shutdownNow();
|
||||
thread.interrupt();
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ---- main loop ----
|
||||
|
||||
private void run() {
|
||||
while (!closed) {
|
||||
long pause = 10_000;
|
||||
try {
|
||||
if (!checkConfig()) pause = 60_000;
|
||||
else {
|
||||
boolean worked = cycle();
|
||||
pause = worked ? 150 : 10_000;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403 || e.status == 404) enabled = false;
|
||||
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
|
||||
pause = 30_000;
|
||||
} catch (Exception e) {
|
||||
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
pause = 60_000;
|
||||
}
|
||||
if (stateDirty) saveState();
|
||||
try {
|
||||
synchronized (wake) {
|
||||
if (!wakeRequested) wake.wait(pause);
|
||||
wakeRequested = false;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkConfig() throws IOException, InterruptedException {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now < configAt) return enabled;
|
||||
configAt = now + 60_000;
|
||||
try {
|
||||
JsonObject config = client.get("map/config");
|
||||
boolean on = config.has("enabled") && config.get("enabled").getAsBoolean();
|
||||
if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt()));
|
||||
if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong());
|
||||
long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0;
|
||||
if (on && epoch != state.epoch) {
|
||||
if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again.");
|
||||
store.clear();
|
||||
state = new State();
|
||||
state.epoch = epoch;
|
||||
renderedAt.clear();
|
||||
stateDirty = true;
|
||||
}
|
||||
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
|
||||
enabled = on;
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
enabled = false; // an older panel without the map
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
|
||||
private boolean cycle() throws IOException, InterruptedException {
|
||||
if (!Files.isDirectory(worldRoot)) {
|
||||
if (!warnedMissing) {
|
||||
warnedMissing = true;
|
||||
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
|
||||
List<Stale> stale = new ArrayList<>();
|
||||
List<Player> online = players;
|
||||
for (Dim dim : DIMS) {
|
||||
Path dir = regionDir(dim);
|
||||
if (dir == null) continue;
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
|
||||
for (Path file : stream) {
|
||||
String[] parts = file.getFileName().toString().split("\\.");
|
||||
int rx, rz;
|
||||
try {
|
||||
rx = Integer.parseInt(parts[1]);
|
||||
rz = Integer.parseInt(parts[2]);
|
||||
} catch (RuntimeException e) {
|
||||
continue;
|
||||
}
|
||||
long mtime = Files.getLastModifiedTime(file).toMillis();
|
||||
String key = dim.dir() + "/" + rx + "." + rz;
|
||||
Long done = state.regions.get(key);
|
||||
if (done != null && mtime <= done) continue;
|
||||
Long last = renderedAt.get(key);
|
||||
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
|
||||
if (Files.size(file) < 8192) continue;
|
||||
double distance = Double.MAX_VALUE;
|
||||
for (Player p : online) {
|
||||
if (!dim.id().equals(p.dimension())) continue;
|
||||
double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256);
|
||||
distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
|
||||
}
|
||||
stale.add(new Stale(dim, file, key, mtime, rx, rz, distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean worked = false;
|
||||
if (!stale.isEmpty()) {
|
||||
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
|
||||
Stale next = stale.get(0);
|
||||
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
|
||||
worked = true;
|
||||
}
|
||||
if (flushUploads(stale.size() <= 1)) worked = true;
|
||||
return worked;
|
||||
}
|
||||
|
||||
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
|
||||
int[][] tiles;
|
||||
try {
|
||||
tiles = RegionRenderer.render(file, dim.kind());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// A region being written right now; try again after the gap.
|
||||
renderedAt.put(key, System.currentTimeMillis());
|
||||
return;
|
||||
}
|
||||
renderedAt.put(key, System.currentTimeMillis());
|
||||
List<TileStore.Key> changed = new ArrayList<>();
|
||||
if (tiles != null) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1));
|
||||
if (store.update(k, tiles[i])) {
|
||||
changed.add(k);
|
||||
state.dirty.add(k.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rebuild the zoom pyramid above whatever changed.
|
||||
List<TileStore.Key> level = changed;
|
||||
for (int z = 0; z < maxZoom && !level.isEmpty(); z++) {
|
||||
Set<Long> seen = new HashSet<>();
|
||||
List<TileStore.Key> next = new ArrayList<>();
|
||||
for (TileStore.Key child : level) {
|
||||
long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL);
|
||||
if (!seen.add(id)) continue;
|
||||
TileStore.Key parent = store.rebuildParent(child);
|
||||
if (parent != null) {
|
||||
next.add(parent);
|
||||
state.dirty.add(parent.toString());
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
state.regions.put(key, mtime);
|
||||
stateDirty = true;
|
||||
}
|
||||
|
||||
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
|
||||
private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException {
|
||||
if (state.dirty.isEmpty()) return false;
|
||||
long now = System.currentTimeMillis();
|
||||
boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS;
|
||||
List<TileStore.Key> ready = new ArrayList<>();
|
||||
for (String s : state.dirty) {
|
||||
TileStore.Key k = TileStore.Key.parse(s);
|
||||
if (k.zoom() == 0 || coarseNow) ready.add(k);
|
||||
}
|
||||
if (ready.isEmpty()) return false;
|
||||
ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed()));
|
||||
if (coarseNow) coarseUploadAt = now;
|
||||
boolean sentAny = false;
|
||||
int batches = 0;
|
||||
int i = 0;
|
||||
while (i < ready.size() && batches < 8 && !closed) {
|
||||
String dimension = ready.get(i).dim();
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
List<TileStore.Key> inBatch = new ArrayList<>();
|
||||
while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) {
|
||||
TileStore.Key k = ready.get(i++);
|
||||
int[] pixels = store.load(k);
|
||||
if (pixels == null) {
|
||||
state.dirty.remove(k.toString());
|
||||
continue;
|
||||
}
|
||||
byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE);
|
||||
body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array());
|
||||
body.writeBytes(png);
|
||||
inBatch.add(k);
|
||||
}
|
||||
if (inBatch.isEmpty()) continue;
|
||||
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
|
||||
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
|
||||
stateDirty = true;
|
||||
sentAny = true;
|
||||
batches++;
|
||||
}
|
||||
return sentAny;
|
||||
}
|
||||
|
||||
private static String idOf(String dir) {
|
||||
for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id();
|
||||
return "minecraft:" + dir;
|
||||
}
|
||||
|
||||
// ---- live data ----
|
||||
|
||||
private void sendLive() {
|
||||
if (closed || !enabled) return;
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
List<Player> snapshot = players;
|
||||
JsonArray list = new JsonArray();
|
||||
for (Player p : snapshot) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("uuid", p.uuid());
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("dimension", p.dimension());
|
||||
o.addProperty("x", Math.round(p.x() * 10) / 10.0);
|
||||
o.addProperty("y", Math.round(p.y() * 10) / 10.0);
|
||||
o.addProperty("z", Math.round(p.z() * 10) / 10.0);
|
||||
o.addProperty("yaw", Math.round(p.yaw()));
|
||||
list.add(o);
|
||||
}
|
||||
String text = list.toString();
|
||||
if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("players", list);
|
||||
client.post("map/players", body);
|
||||
lastPlayersSent = text;
|
||||
playersSentAt = now;
|
||||
}
|
||||
java.util.function.Supplier<String> source = overlay;
|
||||
String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get();
|
||||
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
|
||||
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
|
||||
lastOverlaySent = ov;
|
||||
overlaySentAt = now;
|
||||
}
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403 || e.status == 404) enabled = false;
|
||||
} catch (Exception ignored) {
|
||||
// the next tick retries
|
||||
}
|
||||
}
|
||||
|
||||
// ---- world layout ----
|
||||
|
||||
private Path regionDir(Dim dim) {
|
||||
String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString();
|
||||
Path parent = worldRoot.toAbsolutePath().getParent();
|
||||
List<Path> candidates = switch (dim.dir()) {
|
||||
case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
|
||||
worldRoot.resolve("dimensions/minecraft/the_nether/region"));
|
||||
case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
|
||||
worldRoot.resolve("dimensions/minecraft/the_end/region"));
|
||||
default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region"));
|
||||
};
|
||||
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Path sibling(Path parent, String name, String rel) {
|
||||
return parent == null ? null : parent.resolve(name).resolve(rel);
|
||||
}
|
||||
|
||||
// ---- state ----
|
||||
|
||||
private void loadState() {
|
||||
try {
|
||||
Path f = cacheDir.resolve("state.json");
|
||||
if (Files.isRegularFile(f)) {
|
||||
State s = gson.fromJson(Files.readString(f), State.class);
|
||||
if (s != null && s.regions != null && s.dirty != null) state = s;
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
state = new State();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveState() {
|
||||
try {
|
||||
Files.createDirectories(cacheDir);
|
||||
Path f = cacheDir.resolve("state.json");
|
||||
Path tmp = f.resolveSibling("state.json.tmp");
|
||||
Files.writeString(tmp, gson.toJson(state));
|
||||
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
|
||||
stateDirty = false;
|
||||
} catch (IOException e) {
|
||||
// the cache is only an optimisation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
stone,989898
|
||||
granite,B6775E
|
||||
polished_granite,BC7A63
|
||||
diorite,E5E5E5
|
||||
polished_diorite,EAEBED
|
||||
andesite,A6A6A6
|
||||
polished_andesite,A1A3A2
|
||||
grass_block,618149
|
||||
dirt,A37048
|
||||
coarse_dirt,91633F
|
||||
podzol,6F4913
|
||||
cobblestone,9B9B9B
|
||||
oak_planks,C69A53
|
||||
spruce_planks,8B6231
|
||||
birch_planks,EAD389
|
||||
jungle_planks,C38656
|
||||
acacia_planks,CD622C
|
||||
cherry_planks,FFC2BB
|
||||
dark_oak_planks,513112
|
||||
mangrove_planks,8F3930
|
||||
bamboo_planks,EBD051
|
||||
bamboo_mosaic,E8CC4F
|
||||
bedrock,686868
|
||||
sand,FFEFB6
|
||||
red_sand,E87011
|
||||
gravel,A09A99
|
||||
gold_ore,B1A17C
|
||||
deepslate_gold_ore,8C7B5A
|
||||
iron_ore,A69C93
|
||||
deepslate_iron_ore,817871
|
||||
coal_ore,808080
|
||||
deepslate_coal_ore,5A5A5D
|
||||
nether_gold_ore,8C3929
|
||||
oak_log,B88F4E
|
||||
spruce_log,845E2F
|
||||
birch_log,EBD89C
|
||||
jungle_log,B67F4A
|
||||
acacia_log,B76235
|
||||
cherry_log,E2A6A0
|
||||
dark_oak_log,523414
|
||||
mangrove_log,7C332A
|
||||
mangrove_roots,5A4629
|
||||
muddy_mangrove_roots,554533
|
||||
bamboo_block,A9AC40
|
||||
stripped_spruce_log,805E2F
|
||||
stripped_birch_log,E9CE83
|
||||
stripped_jungle_log,C98F57
|
||||
stripped_acacia_log,CB642D
|
||||
stripped_cherry_log,FFB5AC
|
||||
stripped_dark_oak_log,4F3315
|
||||
stripped_oak_log,C39952
|
||||
stripped_mangrove_log,852B2B
|
||||
stripped_bamboo_block,D9BE48
|
||||
oak_leaves,4D7515
|
||||
spruce_leaves,355C35
|
||||
jungle_leaves,557E14
|
||||
cherry_leaves,FFB8D3
|
||||
dark_oak_leaves,517B16
|
||||
mangrove_leaves,456913
|
||||
azalea_leaves,6A8B2B
|
||||
flowering_azalea_leaves,778742
|
||||
sponge,EEEA49
|
||||
wet_sponge,CFDD45
|
||||
lapis_ore,7E8BAC
|
||||
deepslate_lapis_ore,5B6A8C
|
||||
lapis_block,1447AB
|
||||
dispenser,959393
|
||||
sandstone,FFF3BB
|
||||
chiseled_sandstone,FFECAE
|
||||
cut_sandstone,FFF1B3
|
||||
note_block,6B422A
|
||||
powered_rail,A78151
|
||||
detector_rail,967C69
|
||||
piston,BB975E
|
||||
white_wool,FBFFFF
|
||||
orange_wool,FF7200
|
||||
magenta_wool,E741D9
|
||||
light_blue_wool,2EC8FF
|
||||
yellow_wool,FFC40E
|
||||
lime_wool,7EE207
|
||||
pink_wool,FF8BB1
|
||||
gray_wool,4A5357
|
||||
light_gray_wool,ADADA2
|
||||
cyan_wool,07A6B1
|
||||
purple_wool,8C20D2
|
||||
blue_wool,3137C0
|
||||
brown_wool,8B5026
|
||||
green_wool,638515
|
||||
red_wool,C31E17
|
||||
black_wool,18191E
|
||||
gold_block,FFD328
|
||||
iron_block,FFFFFF
|
||||
bricks,B76F5B
|
||||
tnt,AD4034
|
||||
bookshelf,8F6F3F
|
||||
chiseled_bookshelf,D9AB5E
|
||||
mossy_cobblestone,85906F
|
||||
obsidian,110A1D
|
||||
spawner,28364C
|
||||
diamond_ore,91ACAB
|
||||
deepslate_diamond_ore,628181
|
||||
diamond_block,57FFF4
|
||||
crafting_table,915228
|
||||
farmland,AE764B
|
||||
furnace,868585
|
||||
oak_door,AB8246
|
||||
iron_door,EDEBEB
|
||||
redstone_ore,AB8080
|
||||
deepslate_redstone_ore,7F5556
|
||||
snow,F9FFFF
|
||||
ice,85B0FF
|
||||
cactus,629B28
|
||||
clay,C0C9DA
|
||||
jukebox,714A33
|
||||
netherrack,762626
|
||||
soul_sand,634938
|
||||
soul_soil,5C4334
|
||||
basalt,616269
|
||||
polished_basalt,79777A
|
||||
glowstone,D19A5A
|
||||
nether_portal,6400EA
|
||||
carved_pumpkin,B75D01
|
||||
jack_o_lantern,FFAC27
|
||||
cake,FFE1D8
|
||||
repeater,C3BFBE
|
||||
oak_trapdoor,97753A
|
||||
spruce_trapdoor,7E5D31
|
||||
birch_trapdoor,FDEBB8
|
||||
jungle_trapdoor,B98053
|
||||
acacia_trapdoor,BE602F
|
||||
cherry_trapdoor,FFC2BB
|
||||
dark_oak_trapdoor,5C3814
|
||||
mangrove_trapdoor,862F29
|
||||
bamboo_trapdoor,F2D857
|
||||
stone_bricks,959395
|
||||
mossy_stone_bricks,8B947E
|
||||
cracked_stone_bricks,908F90
|
||||
chiseled_stone_bricks,919091
|
||||
packed_mud,AD7C57
|
||||
mud_bricks,A77958
|
||||
brown_mushroom_block,B68259
|
||||
red_mushroom_block,F42220
|
||||
mushroom_stem,F8EEDF
|
||||
pumpkin,F28404
|
||||
melon,83B014
|
||||
mycelium,87767A
|
||||
nether_bricks,36161D
|
||||
enchanting_table,9C5461
|
||||
cauldron,59585A
|
||||
end_portal_frame,6B9273
|
||||
end_stone,FBFFAD
|
||||
dragon_egg,0E0A12
|
||||
redstone_lamp,743C1B
|
||||
emerald_ore,80A689
|
||||
deepslate_emerald_ore,5B7F68
|
||||
emerald_block,1CF859
|
||||
command_block,D89C78
|
||||
beacon,79FFF9
|
||||
anvil,585858
|
||||
chipped_anvil,585858
|
||||
damaged_anvil,585858
|
||||
comparator,CBC4C1
|
||||
daylight_detector,9F8B6D
|
||||
redstone_block,D61800
|
||||
nether_quartz_ore,8F4844
|
||||
hopper,5C5A5C
|
||||
quartz_block,FFF8EF
|
||||
chiseled_quartz_block,FFF9EE
|
||||
quartz_pillar,FFF8EF
|
||||
activator_rail,8C6654
|
||||
dropper,959393
|
||||
white_terracotta,FFD5BD
|
||||
orange_terracotta,C45A1B
|
||||
magenta_terracotta,B6627E
|
||||
light_blue_terracotta,8680A7
|
||||
yellow_terracotta,E39B15
|
||||
lime_terracotta,7C8F36
|
||||
pink_terracotta,C45353
|
||||
gray_terracotta,463127
|
||||
light_gray_terracotta,A57D71
|
||||
cyan_terracotta,686F6F
|
||||
purple_terracotta,904E64
|
||||
blue_terracotta,58436F
|
||||
brown_terracotta,5E3A25
|
||||
green_terracotta,5C652D
|
||||
red_terracotta,AE3E2A
|
||||
black_terracotta,2D1910
|
||||
slime_block,7CEA60
|
||||
iron_trapdoor,F6F6F6
|
||||
prismarine,6EC6AE
|
||||
prismarine_bricks,6ED1BF
|
||||
dark_prismarine,386F59
|
||||
sea_lantern,CEF4E6
|
||||
hay_block,C9A700
|
||||
terracotta,B96A45
|
||||
coal_block,141212
|
||||
packed_ice,82AFFF
|
||||
red_sandstone,DD6A10
|
||||
chiseled_red_sandstone,DF680A
|
||||
cut_red_sandstone,E76E0F
|
||||
smooth_stone_slab,CCCCCC
|
||||
smooth_stone,C1C1C1
|
||||
bamboo_fence_gate,FBDF57
|
||||
bamboo_fence,FBE05B
|
||||
spruce_door,815E32
|
||||
birch_door,FFF1C6
|
||||
jungle_door,C78B5B
|
||||
acacia_door,CC693A
|
||||
cherry_door,FFBBB3
|
||||
dark_oak_door,5D3B17
|
||||
mangrove_door,89312E
|
||||
bamboo_door,E9CE53
|
||||
chorus_plant,714071
|
||||
chorus_flower,B88EB8
|
||||
purpur_block,CE92CE
|
||||
purpur_pillar,D196D1
|
||||
end_stone_bricks,F7FFB0
|
||||
dirt_path,B59043
|
||||
repeating_command_block,967DCF
|
||||
chain_command_block,9BC4B1
|
||||
nether_wart_block,8B0000
|
||||
red_nether_bricks,540003
|
||||
bone_block,FFFBD6
|
||||
observer,787878
|
||||
shulker_box,AA6FAA
|
||||
white_shulker_box,F7FEFF
|
||||
orange_shulker_box,FF6F00
|
||||
magenta_shulker_box,D330C5
|
||||
light_blue_shulker_box,23BDFF
|
||||
yellow_shulker_box,FFBA03
|
||||
lime_shulker_box,6ED206
|
||||
pink_shulker_box,FF78A4
|
||||
gray_shulker_box,42464C
|
||||
light_gray_shulker_box,97978B
|
||||
cyan_shulker_box,0892A5
|
||||
purple_shulker_box,7615BE
|
||||
blue_shulker_box,2629AB
|
||||
brown_shulker_box,814B20
|
||||
green_shulker_box,5D7A1C
|
||||
red_shulker_box,AB1614
|
||||
black_shulker_box,1E1E23
|
||||
white_glazed_terracotta,DFFFF2
|
||||
orange_glazed_terracotta,BCB266
|
||||
magenta_glazed_terracotta,FE6AE7
|
||||
light_blue_glazed_terracotta,62C2FE
|
||||
yellow_glazed_terracotta,FFCC4D
|
||||
lime_glazed_terracotta,C1F02E
|
||||
pink_glazed_terracotta,FF9DBD
|
||||
gray_glazed_terracotta,646D71
|
||||
light_gray_glazed_terracotta,ACCACC
|
||||
cyan_glazed_terracotta,358F98
|
||||
purple_glazed_terracotta,7F2AB9
|
||||
blue_glazed_terracotta,2C43AA
|
||||
brown_glazed_terracotta,917F63
|
||||
green_glazed_terracotta,8BAD47
|
||||
red_glazed_terracotta,DD362E
|
||||
black_glazed_terracotta,521F22
|
||||
white_concrete,F6FEFF
|
||||
orange_concrete,FF6E00
|
||||
magenta_concrete,CE29C1
|
||||
light_blue_concrete,139EF2
|
||||
yellow_concrete,FFB300
|
||||
lime_concrete,68CD08
|
||||
pink_concrete,FF69A0
|
||||
gray_concrete,41454A
|
||||
light_gray_concrete,98988B
|
||||
cyan_concrete,098FA6
|
||||
purple_concrete,7214BE
|
||||
blue_concrete,272AAE
|
||||
brown_concrete,75431C
|
||||
green_concrete,566F24
|
||||
red_concrete,AD1717
|
||||
black_concrete,090B12
|
||||
white_concrete_powder,FCFFFF
|
||||
orange_concrete_powder,FF8608
|
||||
magenta_concrete_powder,EA55DF
|
||||
light_blue_concrete_powder,45D3FF
|
||||
yellow_concrete_powder,FFD624
|
||||
lime_concrete_powder,8FE71C
|
||||
pink_concrete_powder,FFA1C4
|
||||
gray_concrete_powder,5C6266
|
||||
light_gray_concrete_powder,BCBCB4
|
||||
cyan_concrete_powder,1AB2C0
|
||||
purple_concrete_powder,9931D8
|
||||
blue_concrete_powder,474BCB
|
||||
brown_concrete_powder,986036
|
||||
green_concrete_powder,73912B
|
||||
red_concrete_powder,CD312C
|
||||
black_concrete_powder,1E1F26
|
||||
dead_tube_coral_block,9F9590
|
||||
dead_brain_coral_block,978E8A
|
||||
dead_bubble_coral_block,A0958F
|
||||
dead_fire_coral_block,A0958F
|
||||
dead_horn_coral_block,A29993
|
||||
tube_coral_block,2559FB
|
||||
brain_coral_block,FD5EBB
|
||||
bubble_coral_block,C90BC5
|
||||
fire_coral_block,C71827
|
||||
horn_coral_block,FFE939
|
||||
sea_pickle,6D7627
|
||||
blue_ice,649EFF
|
||||
bamboo,EBD051
|
||||
scaffolding,CF9A4B
|
||||
loom,AD8E68
|
||||
barrel,A3753C
|
||||
smoker,686562
|
||||
blast_furnace,616163
|
||||
cartography_table,7E684C
|
||||
fletching_table,F0D999
|
||||
lectern,D3A258
|
||||
smithing_table,444555
|
||||
stonecutter,968F86
|
||||
warped_stem,388586
|
||||
stripped_warped_stem,349C97
|
||||
warped_nylium,2A8B79
|
||||
warped_wart_block,0C9194
|
||||
crimson_stem,89334F
|
||||
stripped_crimson_stem,943B5E
|
||||
crimson_nylium,9F1717
|
||||
shroomlight,FF8F35
|
||||
crimson_planks,7B3351
|
||||
warped_planks,2C7F78
|
||||
crimson_trapdoor,7E3553
|
||||
warped_trapdoor,2F9186
|
||||
crimson_door,8B395B
|
||||
warped_door,2A9A92
|
||||
structure_block,6B586E
|
||||
jigsaw,615263
|
||||
target,FFB8A8
|
||||
bee_nest,F6BD48
|
||||
beehive,C0954F
|
||||
honey_block,FFB41D
|
||||
honeycomb_block,FF9A06
|
||||
netherite_block,514A4C
|
||||
ancient_debris,734C41
|
||||
crying_obsidian,230549
|
||||
respawn_anchor,520BB6
|
||||
lodestone,B3B5B9
|
||||
blackstone,332B32
|
||||
polished_blackstone,403944
|
||||
polished_blackstone_bricks,3A323C
|
||||
cracked_polished_blackstone_bricks,362C34
|
||||
chiseled_polished_blackstone,403944
|
||||
gilded_blackstone,43312C
|
||||
chiseled_nether_bricks,39191F
|
||||
cracked_nether_bricks,31151A
|
||||
quartz_bricks,FFF9EF
|
||||
amethyst_block,9A69E9
|
||||
budding_amethyst,9968E3
|
||||
tuff,84857B
|
||||
polished_tuff,757F78
|
||||
chiseled_tuff,71786E
|
||||
tuff_bricks,777C73
|
||||
chiseled_tuff_bricks,868A80
|
||||
calcite,FEFFFA
|
||||
powder_snow,F9FFFF
|
||||
sculk_sensor,005466
|
||||
calibrated_sculk_sensor,165D7A
|
||||
sculk,0D2028
|
||||
sculk_vein,013A47
|
||||
sculk_catalyst,0F252E
|
||||
copper_block,EA7650
|
||||
exposed_copper,C49375
|
||||
weathered_copper,7DBB80
|
||||
oxidized_copper,58C69D
|
||||
copper_ore,979892
|
||||
deepslate_copper_ore,70716C
|
||||
oxidized_cut_copper,56BB96
|
||||
weathered_cut_copper,80B17D
|
||||
exposed_cut_copper,BC8F73
|
||||
cut_copper,E97551
|
||||
oxidized_chiseled_copper,5AC49D
|
||||
weathered_chiseled_copper,78B782
|
||||
exposed_chiseled_copper,BC8C72
|
||||
chiseled_copper,E06E49
|
||||
copper_door,EA7954
|
||||
exposed_copper_door,C89079
|
||||
oxidized_copper_door,59C39D
|
||||
weathered_copper_door,80B77F
|
||||
copper_trapdoor,E87550
|
||||
exposed_copper_trapdoor,C49377
|
||||
oxidized_copper_trapdoor,5AC49D
|
||||
weathered_copper_trapdoor,7DBB80
|
||||
copper_grate,E97650
|
||||
exposed_copper_grate,C49377
|
||||
weathered_copper_grate,7BB980
|
||||
oxidized_copper_grate,58C49B
|
||||
copper_bulb,BE5F37
|
||||
exposed_copper_bulb,A57E66
|
||||
weathered_copper_bulb,6B9A75
|
||||
oxidized_copper_bulb,4CA182
|
||||
dripstone_block,A37F6A
|
||||
azalea,78972E
|
||||
flowering_azalea,879446
|
||||
moss_block,6A852E
|
||||
big_dripleaf,83AC31
|
||||
rooted_dirt,B07853
|
||||
mud,494549
|
||||
deepslate,6A6A6D
|
||||
cobbled_deepslate,5E5E62
|
||||
polished_deepslate,585859
|
||||
deepslate_tiles,424243
|
||||
deepslate_bricks,555557
|
||||
chiseled_deepslate,424242
|
||||
cracked_deepslate_bricks,4E4E4F
|
||||
cracked_deepslate_tiles,3F3F3F
|
||||
smooth_basalt,57575F
|
||||
raw_iron_block,CBA07A
|
||||
raw_copper_block,BC7955
|
||||
raw_gold_block,FFBC1D
|
||||
ochre_froglight,FFF9CD
|
||||
verdant_froglight,EDFFEC
|
||||
pearlescent_froglight,FFF9F8
|
||||
reinforced_deepslate,61645F
|
||||
crafter,897677
|
||||
trial_spawner,2D455D
|
||||
vault,3E5360
|
||||
heavy_core,626873
|
||||
|
@@ -22,7 +22,7 @@ class EssentialsServiceTest {
|
||||
public Pos pos() { return pos; }
|
||||
public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
|
||||
public void teleport(Pos p) { pos = p; }
|
||||
public boolean hasPermission(String n) { return true; }
|
||||
public boolean hasPermission(String n) { return !n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass"); }
|
||||
public long playtimeSeconds() { return 0; }
|
||||
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
|
||||
public void clearHeld() { }
|
||||
|
||||
@@ -17,6 +17,8 @@ final class Kit {
|
||||
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
|
||||
final List<String> inbox = new ArrayList<>();
|
||||
final Set<String> denied = new HashSet<>();
|
||||
/** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */
|
||||
final Set<String> granted = new HashSet<>();
|
||||
Item held;
|
||||
final List<Item> inventory = new ArrayList<>();
|
||||
long playtime = 3700;
|
||||
@@ -26,7 +28,7 @@ final class Kit {
|
||||
public Pos pos() { return pos; }
|
||||
public void send(String m) { inbox.add(Format.plain(m)); }
|
||||
public void teleport(Pos p) { pos = p; }
|
||||
public boolean hasPermission(String n) { return !denied.contains(n); }
|
||||
public boolean hasPermission(String n) { return granted.contains(n) || (!n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass") && !denied.contains(n)); }
|
||||
public long playtimeSeconds() { return playtime; }
|
||||
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
|
||||
public void clearHeld() { held = null; }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LimitsTest {
|
||||
@Test void permissionTiersRaiseALimit() {
|
||||
assertEquals(5, Limits.tier(n -> false, "scopenet.homes.", 5, 500));
|
||||
assertEquals(10, Limits.tier(Set.of("scopenet.homes.10")::contains, "scopenet.homes.", 5, 500));
|
||||
assertEquals(25, Limits.tier(Set.of("scopenet.homes.10", "scopenet.homes.25", "scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "highest wins");
|
||||
assertEquals(5, Limits.tier(Set.of("scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "a lower tier never reduces the base");
|
||||
assertEquals(Integer.MAX_VALUE, Limits.tier(Set.of("scopenet.homes.unlimited")::contains, "scopenet.homes.", 5, 500));
|
||||
assertEquals("unlimited", Limits.show(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test void homesFollowConfigAndPermissions(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(2, 2500, 60_000, 15, 0, 1, 8, Map.of());
|
||||
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, kit.env.rng);
|
||||
svc.setHome(steve, new String[]{"a"});
|
||||
svc.setHome(steve, new String[]{"b"});
|
||||
svc.setHome(steve, new String[]{"c"});
|
||||
assertTrue(steve.last().contains("limit of 2"));
|
||||
steve.granted.add("scopenet.homes.4");
|
||||
svc.setHome(steve, new String[]{"c"});
|
||||
svc.setHome(steve, new String[]{"d"});
|
||||
svc.setHome(steve, new String[]{"e"});
|
||||
assertTrue(steve.last().contains("limit of 4"), steve.last());
|
||||
svc.setHome(steve, new String[]{"waytoolongname"});
|
||||
assertTrue(steve.last().contains("at most 8"), steve.last());
|
||||
svc.setWarp(steve, "spawn");
|
||||
svc.setWarp(steve, "pvp");
|
||||
assertTrue(steve.last().contains("already has 1 warps"), steve.last());
|
||||
}
|
||||
|
||||
@Test void teleportCommandsCoolDownOnlyAfterTeleporting(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(5, 2500, 60_000, 15, 0, 0, 32, Map.of("home", 30, "warp", 10));
|
||||
CommandSet set = new CommandSet(kit.env, cfg);
|
||||
set.essentials.setHome(steve, new String[]{"base"});
|
||||
set.run("home", steve, "nope"); // unknown home: nothing happened, no cooldown
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), steve.last());
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Please wait 30s"), steve.last());
|
||||
kit.now.addAndGet(31_000);
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), steve.last());
|
||||
set.run("warp", steve, "missing");
|
||||
set.run("warp", steve, "missing");
|
||||
assertFalse(steve.last().contains("Please wait"), "a failed warp costs nothing");
|
||||
steve.granted.add("scopenet.cooldown.bypass");
|
||||
set.run("home", steve, "base");
|
||||
assertTrue(steve.last().contains("Teleported to home"), "bypass skips cooldowns");
|
||||
}
|
||||
|
||||
@Test void rtpKeepsAwayFromTheCentre(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
EssentialsConfig cfg = new EssentialsConfig(5, 400, 60_000, 50, 300, 0, 32, Map.of());
|
||||
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, new java.util.Random(7));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
svc.rtp(steve);
|
||||
int x = (int) steve.pos.x(), z = (int) steve.pos.z();
|
||||
assertTrue(Math.max(Math.abs(x), Math.abs(z)) >= 300, "landed at " + x + "," + z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.map.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapOverlayTest {
|
||||
@Test void overlayCarriesShapesAndPlainText() {
|
||||
var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16)));
|
||||
var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron <Wolves>", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8,
|
||||
"<div class=\"scopenet-detail\"><strong>Iron <Wolves></strong> <span>[IRN]</span><br>1 claimed chunk in this area</div>");
|
||||
var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6,
|
||||
"<div class=\"scopenet-detail\"><strong>Warp</strong> a<br><code>/warp a</code></div>");
|
||||
JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of()));
|
||||
JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject();
|
||||
assertEquals("#3366cc", c.get("color").getAsString());
|
||||
assertEquals(4, c.getAsJsonArray("outer").size());
|
||||
assertEquals("Iron <Wolves> [IRN]", c.getAsJsonArray("lines").get(0).getAsString());
|
||||
assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString());
|
||||
JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject();
|
||||
assertEquals("warp", p.get("kind").getAsString());
|
||||
assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString());
|
||||
assertFalse(json.toString().contains("<div"), "no markup reaches the viewer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import net.scopenet.core.map.*;
|
||||
import net.scopenet.integration.ClaimIndex;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapTest {
|
||||
@TempDir Path dir;
|
||||
|
||||
static Set<Long> chunks(String... rows) {
|
||||
Set<Long> set = new HashSet<>();
|
||||
for (int z = 0; z < rows.length; z++) for (int x = 0; x < rows[z].length(); x++) if (rows[z].charAt(x) == '#') set.add(ChunkRegions.key(x, z));
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---- outlines --------------------------------------------------------------------
|
||||
|
||||
@Test void singleChunkIsASixteenBlockSquare() {
|
||||
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("#"));
|
||||
assertEquals(1, r.size());
|
||||
assertEquals(4, r.get(0).outer().points().size());
|
||||
assertEquals(256, r.get(0).outer().area(), 0.001);
|
||||
assertEquals(8, r.get(0).labelX());
|
||||
assertEquals(8, r.get(0).labelZ());
|
||||
}
|
||||
|
||||
@Test void straightEdgesAreMergedIntoOneSide() {
|
||||
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("####", "####"));
|
||||
assertEquals(4, r.get(0).outer().points().size(), "a 4x2 block is still just a rectangle");
|
||||
assertEquals(8 * 256, r.get(0).outer().area(), 0.001);
|
||||
}
|
||||
|
||||
@Test void lShapeHasSixCorners() {
|
||||
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("#.", "#.", "##"));
|
||||
assertEquals(1, r.size());
|
||||
assertEquals(6, r.get(0).outer().points().size());
|
||||
assertEquals(4 * 256, r.get(0).outer().area(), 0.001);
|
||||
assertEquals(4, r.get(0).chunks());
|
||||
}
|
||||
|
||||
@Test void aDonutKeepsItsHole() {
|
||||
List<ChunkRegions.Region> r = ChunkRegions.regions(chunks("###", "#.#", "###"));
|
||||
assertEquals(1, r.size());
|
||||
assertEquals(9 * 256, r.get(0).outer().area(), 0.001);
|
||||
assertEquals(1, r.get(0).holes().size());
|
||||
assertEquals(-256, r.get(0).holes().get(0).area(), 0.001);
|
||||
assertEquals(8, r.get(0).chunks());
|
||||
assertTrue(r.get(0).holes().get(0).contains(24, 24), "the hole is the middle chunk");
|
||||
}
|
||||
|
||||
@Test void separateAreasAreSeparateRegionsAndDiagonalsDoNotJoin() {
|
||||
List<ChunkRegions.Region> apart = ChunkRegions.regions(chunks("#.....#"));
|
||||
assertEquals(2, apart.size());
|
||||
List<ChunkRegions.Region> diagonal = ChunkRegions.regions(chunks("#.", ".#"));
|
||||
assertEquals(2, diagonal.size(), "chunks that only touch at a corner are two areas");
|
||||
for (ChunkRegions.Region region : diagonal) assertEquals(256, region.outer().area(), 0.001);
|
||||
}
|
||||
|
||||
@Test void theLabelSitsInsideABentShape() {
|
||||
// A "U": the average is in the gap, but the label must be on land.
|
||||
Set<Long> u = chunks("#.#", "#.#", "###");
|
||||
ChunkRegions.Region r = ChunkRegions.regions(u).get(0);
|
||||
int cx = (int) Math.floor(r.labelX() / 16), cz = (int) Math.floor(r.labelZ() / 16);
|
||||
assertTrue(u.contains(ChunkRegions.key(cx, cz)));
|
||||
}
|
||||
|
||||
@Test void negativeCoordinatesWork() {
|
||||
Set<Long> set = new HashSet<>(List.of(ChunkRegions.key(-3, -2), ChunkRegions.key(-2, -2), ChunkRegions.key(-3, -1)));
|
||||
ChunkRegions.Region r = ChunkRegions.regions(set).get(0);
|
||||
assertEquals(3 * 256, r.outer().area(), 0.001);
|
||||
assertEquals(-48, r.outer().points().stream().mapToDouble(ChunkRegions.Point::x).min().getAsDouble());
|
||||
assertEquals(-32, r.outer().points().stream().mapToDouble(ChunkRegions.Point::z).min().getAsDouble());
|
||||
}
|
||||
|
||||
@Test void aBigRandomBlobStaysConsistent() {
|
||||
Random rng = new Random(7);
|
||||
Set<Long> blob = new HashSet<>();
|
||||
for (int i = 0; i < 400; i++) blob.add(ChunkRegions.key(rng.nextInt(20), rng.nextInt(20)));
|
||||
double land = 0;
|
||||
for (ChunkRegions.Region r : ChunkRegions.regions(blob)) {
|
||||
double holes = r.holes().stream().mapToDouble(h -> -h.area()).sum();
|
||||
land += r.outer().area() - holes;
|
||||
}
|
||||
// Area inside all outlines (minus holes) is exactly the claimed land.
|
||||
assertEquals(blob.size() * 256.0, land, 0.01);
|
||||
}
|
||||
|
||||
// ---- the model -----------------------------------------------------------------------
|
||||
|
||||
static ClaimIndex.GuildClaims guild(String id, String name, String tag, String icon, Set<Long> c) { return new ClaimIndex.GuildClaims(id, name, tag, icon, c); }
|
||||
|
||||
static MapModel.Sources sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Collection<ShopPoints.Shop> shops, JsonArray market, boolean homes) {
|
||||
Pos spawn = new Pos("minecraft:overworld", 0, 80, 0, 0, 0);
|
||||
UUID owner = UUID.randomUUID();
|
||||
return new MapModel.Sources(claims, Map.of("arena", new Pos("minecraft:overworld", 100, 65, -40, 0, 0)), List.of(spawn),
|
||||
Map.of(owner, Map.of("base", new Pos("minecraft:overworld", 5, 70, 5, 0, 0))), Map.of("g1", new MapModel.NamedPos("[IRON] Iron Wolves", new Pos("minecraft:overworld", 32, 70, 32, 0, 0))),
|
||||
shops, market, List.of(), "https://panel.example.com", homes, Map.of(owner, "Alex"));
|
||||
}
|
||||
|
||||
@Test void claimsBecomeColouredRegionsWithNamesAndIcons() {
|
||||
Map<String, List<ClaimIndex.GuildClaims>> claims = Map.of("minecraft:overworld", List.of(
|
||||
guild("g1", "Iron <Wolves>", "IRON", "/textures/abc", chunks("##", "#.")),
|
||||
guild("g2", "Void", "VOID", "https://cdn.example.com/v.png", chunks("#"))));
|
||||
MapData data = MapModel.build(sources(claims, List.of(), new JsonArray(), false));
|
||||
assertEquals(2, data.claims().size());
|
||||
MapData.ClaimRegion iron = data.claims().stream().filter(c -> c.tag().equals("IRON")).findFirst().orElseThrow();
|
||||
assertEquals("https://panel.example.com/textures/abc", iron.icon(), "panel paths become full addresses");
|
||||
assertEquals("https://cdn.example.com/v.png", data.claims().stream().filter(c -> c.tag().equals("VOID")).findFirst().orElseThrow().icon());
|
||||
assertFalse(iron.detail().contains("<Wolves>"), "names are HTML-escaped: " + iron.detail());
|
||||
assertTrue(iron.detail().contains("<Wolves>"));
|
||||
assertEquals(MapModel.guildColor("g1"), iron.color());
|
||||
assertNotEquals(MapModel.guildColor("g1"), MapModel.guildColor("g2"));
|
||||
assertTrue(iron.color() >= 0 && iron.color() <= 0xFFFFFF);
|
||||
assertEquals("", MapModel.iconAddress("javascript:alert(1)", "https://p"), "only web addresses and panel paths are used as icons");
|
||||
assertEquals("", MapModel.iconAddress(null, "https://p"));
|
||||
}
|
||||
|
||||
@Test void pinsCoverSpawnWarpsGuildHomesAndShopsButHomesAreOptIn() {
|
||||
List<ShopPoints.Shop> shops = List.of(new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0)),
|
||||
new ShopPoints.Shop("Smith", "shop", new Pos("minecraft:overworld", 20, 64, 10, 0, 0)));
|
||||
MapData off = MapModel.build(sources(Map.of(), shops, new JsonArray(), false));
|
||||
Set<String> kinds = new TreeSet<>();
|
||||
off.pins().forEach(p -> kinds.add(p.kind()));
|
||||
assertEquals(new TreeSet<>(List.of("guild_home", "market", "shop", "spawn", "warp")), kinds, "homes stay private by default");
|
||||
MapData on = MapModel.build(sources(Map.of(), shops, new JsonArray(), true));
|
||||
MapData.Pin home = on.pins().stream().filter(p -> p.kind().equals("home")).findFirst().orElseThrow();
|
||||
assertEquals("Alex's home", home.label());
|
||||
assertEquals("[IRON] Iron Wolves", on.pins().stream().filter(p -> p.kind().equals("guild_home")).findFirst().orElseThrow().label());
|
||||
}
|
||||
|
||||
@Test void marketPinsSummariseActivity() {
|
||||
JsonArray market = JsonParser.parseString("[{\"id\":1,\"seller_name\":\"Mia\",\"seller_guild\":null,\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5},"
|
||||
+ "{\"id\":2,\"seller_name\":\"X\",\"seller_guild\":\"IRON\",\"item_name\":\"<b>Hack</b>\",\"amount\":1,\"price\":5}]").getAsJsonArray();
|
||||
ShopPoints.Shop shop = new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0));
|
||||
MapData data = MapModel.build(sources(Map.of(), List.of(shop), market, false));
|
||||
String detail = data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail();
|
||||
assertTrue(detail.contains("2 listings"));
|
||||
assertTrue(detail.contains("Diamond x3 · 99.50"));
|
||||
assertTrue(detail.contains("[IRON] guild"));
|
||||
assertFalse(detail.contains("<b>Hack"), "listing names can't inject HTML");
|
||||
assertTrue(MapModel.build(sources(Map.of(), List.of(shop), new JsonArray(), false)).pins().stream().anyMatch(p -> p.detail().contains("No listings")));
|
||||
}
|
||||
|
||||
@Test void mapsAreMatchedToDimensions() {
|
||||
assertEquals("minecraft:overworld", MapModel.dimensionOf("world", "World"));
|
||||
assertEquals("minecraft:the_nether", MapModel.dimensionOf("world_nether", "Nether"));
|
||||
assertEquals("minecraft:the_end", MapModel.dimensionOf("world_the_end", "The End"));
|
||||
assertEquals("minecraft:overworld", MapModel.dimensionOf("legends", "Friends & Legends"), "'end' inside another word isn't the End");
|
||||
}
|
||||
|
||||
@Test void playersJsonHasOnlyPublicFacts() {
|
||||
JsonObject j = MapModel.playersJson("SMP", List.of(new MapData.OnlinePlayer("u-1", "Alex", 12, "Miner", 4, "Iron", "IRON", "leader", "VIP", 7300)), 123L);
|
||||
JsonObject a = j.getAsJsonObject("players").getAsJsonObject("u-1");
|
||||
assertEquals(12, a.get("level").getAsInt());
|
||||
assertEquals("IRON", a.get("guildTag").getAsString());
|
||||
assertFalse(a.has("balance"));
|
||||
assertEquals("SMP", j.get("server").getAsString());
|
||||
}
|
||||
|
||||
// ---- shop points ---------------------------------------------------------------------
|
||||
|
||||
@Test void shopPointsAreManagedByStaffAndSurviveARestart() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex");
|
||||
ShopPoints points = new ShopPoints(dir.resolve("shops.json"));
|
||||
Pos block = new Pos("minecraft:overworld", 10.4, 64.2, -3.7, 0, 0);
|
||||
alex.denied.add(ShopPoints.NODE);
|
||||
points.command(alex, new String[]{"add", "Bazaar"}, block);
|
||||
assertTrue(alex.last().contains("permission"));
|
||||
alex.denied.clear();
|
||||
points.command(alex, new String[]{"add", "bad name"}, block);
|
||||
assertTrue(alex.last().contains("Names are"));
|
||||
points.command(alex, new String[]{"add", "Bazaar"}, block);
|
||||
assertTrue(alex.last().contains("Shop point Bazaar set (market)"));
|
||||
points.command(alex, new String[]{"add", "Twin", "shop"}, block);
|
||||
assertTrue(alex.last().contains("already a shop point"));
|
||||
points.command(alex, new String[]{"add", "Smith", "shop"}, new Pos("minecraft:overworld", 12, 64, -3, 0, 0));
|
||||
|
||||
assertEquals("Bazaar", points.at("minecraft:overworld", 10, 64, -4).orElseThrow().name(), "block coordinates floor correctly");
|
||||
assertTrue(points.at("minecraft:the_nether", 10, 64, -4).isEmpty());
|
||||
assertTrue(points.at("minecraft:overworld", 11, 64, -4).isEmpty());
|
||||
|
||||
ShopPoints reopened = new ShopPoints(dir.resolve("shops.json"));
|
||||
assertEquals(2, reopened.all().size());
|
||||
reopened.command(alex, new String[]{"remove", "bazaar"}, block);
|
||||
assertEquals(1, reopened.all().size());
|
||||
reopened.command(alex, new String[]{"remove", "ghost"}, block);
|
||||
assertTrue(alex.last().contains("No shop point"));
|
||||
reopened.command(alex, new String[]{"list"}, block);
|
||||
assertTrue(alex.heard("Smith (shop)"));
|
||||
}
|
||||
|
||||
@Test void marketPointCommandUsesTheBlockUnderFoot() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex");
|
||||
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
|
||||
alex.pos = new Pos("minecraft:overworld", 10.5, 65, 20.5, 0, 0);
|
||||
set.run("market", alex, "point", "add", "Bazaar");
|
||||
assertTrue(set.shops.at("minecraft:overworld", 10, 64, 20).isPresent());
|
||||
}
|
||||
|
||||
// ---- actions from the map ------------------------------------------------------------
|
||||
|
||||
@Test void pollerCarriesOutMessagesNoticesAndTeleportRequests() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve");
|
||||
List<String> tpas = new ArrayList<>();
|
||||
ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name() + "->" + to.name()));
|
||||
String json = "{\"actions\":[{\"id\":1,\"kind\":\"message\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"hello\"},"
|
||||
+ "{\"id\":2,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"},"
|
||||
+ "{\"id\":3,\"kind\":\"notify\",\"from_uuid\":\"\",\"from_name\":\"\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"§eAlex invited you\"},"
|
||||
+ "{\"id\":4,\"kind\":\"message\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + UUID.randomUUID() + "\",\"text\":\"to nobody\"},"
|
||||
+ "{\"id\":5,\"kind\":\"explode\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}";
|
||||
kit.panel.on("actions/poll", json);
|
||||
poller.tick();
|
||||
assertTrue(steve.heard("[Map] Alex: hello"));
|
||||
assertEquals(List.of("Alex->Steve"), tpas);
|
||||
assertTrue(steve.heard("Alex invited you"));
|
||||
assertEquals(2, steve.inbox.size(), "unknown actions and ones for absent players do nothing");
|
||||
|
||||
// It asks the panel at most every few seconds, and never overlaps itself.
|
||||
kit.panel.calls.clear();
|
||||
poller.tick();
|
||||
poller.tick();
|
||||
assertTrue(kit.panel.calls.isEmpty());
|
||||
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
|
||||
poller.tick();
|
||||
assertEquals(1, kit.panel.calls.size());
|
||||
}
|
||||
|
||||
@Test void teleportRequestsNeedThePermissionAndAPresentRequester() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve");
|
||||
List<String> tpas = new ArrayList<>();
|
||||
ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name()));
|
||||
alex.denied.add("scopenet.command.tpa");
|
||||
kit.panel.on("actions/poll", "{\"actions\":[{\"id\":1,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}");
|
||||
poller.tick();
|
||||
assertTrue(tpas.isEmpty());
|
||||
assertTrue(alex.last().contains("do not have permission"));
|
||||
|
||||
kit.platform.online.remove(alex.id);
|
||||
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
|
||||
poller.tick();
|
||||
assertTrue(steve.heard("has left"));
|
||||
}
|
||||
|
||||
@Test void thePollerSurvivesAPanelOutage() {
|
||||
Kit kit = new Kit(dir);
|
||||
kit.platform.add("Alex");
|
||||
ActionPoller poller = new ActionPoller(kit.env, (a, b) -> { });
|
||||
kit.panel.handlers.put("actions/poll", b -> { throw new java.io.IOException("down"); });
|
||||
poller.tick();
|
||||
kit.now.addAndGet(ActionPoller.EVERY_MS + 1);
|
||||
kit.panel.on("actions/poll", "{\"actions\":[]}");
|
||||
poller.tick();
|
||||
assertEquals(2, kit.panel.calls.size());
|
||||
}
|
||||
|
||||
// ---- guild invitations in game -----------------------------------------------------------
|
||||
|
||||
@Test void guildInviteAcceptAndDeclineCommands() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex");
|
||||
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
|
||||
kit.panel.on("guilds/invite/send", "{\"ok\":true,\"player\":\"Steve\"}");
|
||||
set.run("guild", alex, "invite", "steve");
|
||||
assertTrue(alex.heard("Invitation sent to Steve"));
|
||||
assertEquals("steve", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("target").getAsString());
|
||||
set.run("guild", alex, "invite");
|
||||
assertTrue(alex.last().startsWith("Usage"));
|
||||
|
||||
kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":true,\"guild\":\"Iron\",\"tag\":\"IRON\"}");
|
||||
set.run("guild", alex, "accept", "IRON");
|
||||
assertTrue(alex.heard("Welcome to [IRON] Iron"));
|
||||
assertTrue(kit.panel.claimsRefreshed);
|
||||
assertEquals("IRON", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("tag").getAsString());
|
||||
kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":false,\"guild\":\"Iron\",\"tag\":\"IRON\"}");
|
||||
set.run("guild", alex, "decline");
|
||||
assertTrue(alex.last().contains("declined"));
|
||||
alex.denied.add("scopenet.command.guild.invite");
|
||||
set.run("guild", alex, "invite", "x");
|
||||
assertTrue(alex.last().contains("permission to use /guild invite"));
|
||||
}
|
||||
|
||||
@Test void mapServiceAssemblesEverythingOnTheServerSide() {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player alex = kit.platform.add("Alex");
|
||||
kit.panel.on("player/info", LinkTest.INFO);
|
||||
CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults());
|
||||
PlayerCache cache = new PlayerCache(kit.env);
|
||||
cache.refresh(alex, null);
|
||||
alex.pos = new Pos("minecraft:overworld", 100, 65, 100, 0, 0);
|
||||
set.essentials.setWarp(alex, "Arena");
|
||||
set.run("market", alex, "point", "add", "Bazaar");
|
||||
kit.panel.on("economy/market", "[{\"id\":1,\"seller_name\":\"Mia\",\"item_name\":\"Diamond\",\"amount\":1,\"price\":5}]");
|
||||
ClaimIndex claims = new ClaimIndex();
|
||||
claims.apply(JsonParser.parseString("{\"revision\":\"1\",\"guilds\":[{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"icon_url\":\"/textures/x\"}],"
|
||||
+ "\"claims\":[[\"minecraft:overworld\",0,0,0],[\"minecraft:overworld\",1,0,0]],\"members\":{\"g1\":[]}}").getAsJsonObject());
|
||||
MapService service = new MapService(kit.env, claims, set.mapSource(), cache, "https://panel.example.com", false, "SMP");
|
||||
service.tick();
|
||||
MapData data = service.snapshot();
|
||||
assertEquals(1, data.claims().size());
|
||||
assertEquals("https://panel.example.com/textures/x", data.claims().get(0).icon());
|
||||
assertEquals(2, data.claims().get(0).chunks());
|
||||
Set<String> kinds = new TreeSet<>();
|
||||
data.pins().forEach(p -> kinds.add(p.kind()));
|
||||
assertEquals(new TreeSet<>(List.of("spawn", "warp", "market")), kinds);
|
||||
assertTrue(data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail().contains("1 listing"));
|
||||
assertEquals("Alex", data.players().get(0).name());
|
||||
assertEquals(12, data.players().get(0).level());
|
||||
assertEquals("IRON", service.playersJson().getAsJsonObject("players").getAsJsonObject(alex.id.toString()).get("guildTag").getAsString());
|
||||
|
||||
// Turning claims off hides them.
|
||||
kit.env.features = new Features(true, true, true, true, false, true, "$");
|
||||
assertTrue(service.snapshot().claims().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RewardPollerTest {
|
||||
/** A platform that records what was given and run. */
|
||||
static final class Recording implements Platform {
|
||||
final Kit.FakePlatform inner;
|
||||
final List<String> given = new ArrayList<>(), ran = new ArrayList<>();
|
||||
boolean knowsItems = true, knowsCommands = true;
|
||||
Recording(Kit.FakePlatform inner) { this.inner = inner; }
|
||||
public java.util.Optional<CorePlayer> player(java.util.UUID u) { return inner.player(u); }
|
||||
public java.util.Optional<CorePlayer> playerByName(String n) { return inner.playerByName(n); }
|
||||
public java.util.Collection<? extends CorePlayer> online() { return inner.online(); }
|
||||
public java.util.Optional<Pos> safeSurface(String w, int x, int z) { return inner.safeSurface(w, x, z); }
|
||||
public Pos worldSpawn(String w) { return inner.worldSpawn(w); }
|
||||
public void runMain(Runnable r) { r.run(); }
|
||||
public void runAsync(Runnable r) { r.run(); }
|
||||
@Override public boolean giveItem(java.util.UUID p, String item, int amount) { if (knowsItems) given.add(item + "x" + amount); return knowsItems; }
|
||||
@Override public boolean console(String c) { if (knowsCommands) ran.add(c); return knowsCommands; }
|
||||
}
|
||||
|
||||
private static JsonObject delivery(long id, String uuid, String kind, String payload) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", id);
|
||||
o.addProperty("uuid", uuid);
|
||||
o.addProperty("kind", kind);
|
||||
o.add("payload", JsonParser.parseString(payload));
|
||||
return o;
|
||||
}
|
||||
|
||||
@Test void rewardsAreCarriedOutAndReported(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
Recording platform = new Recording(kit.platform);
|
||||
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
|
||||
JsonObject[] ack = new JsonObject[1];
|
||||
kit.panel.handlers.put("rewards/ack", b -> { ack[0] = b; return new JsonObject(); });
|
||||
kit.panel.handlers.put("rewards/poll", b -> {
|
||||
JsonArray list = new JsonArray();
|
||||
String id = steve.id.toString();
|
||||
list.add(delivery(1, id, "item", "{\"item\":\"minecraft:diamond\",\"amount\":3}"));
|
||||
list.add(delivery(2, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":0}"));
|
||||
list.add(delivery(3, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":10080}"));
|
||||
list.add(delivery(4, id, "group", "{\"group\":\"vip\"}"));
|
||||
list.add(delivery(5, id, "command", "{\"command\":\"give {player} cake 1\"}"));
|
||||
list.add(delivery(6, id, "message", "{\"text\":\"Well done!\"}"));
|
||||
JsonObject o = new JsonObject();
|
||||
o.add("deliveries", list);
|
||||
return o;
|
||||
});
|
||||
RewardPoller poller = new RewardPoller(env, RewardRules.defaults());
|
||||
poller.tick();
|
||||
assertEquals(List.of("minecraft:diamondx3"), platform.given);
|
||||
assertEquals(List.of("lp user Steve permission set essentials.fly true", "lp user Steve permission settemp essentials.fly true 10080m",
|
||||
"lp user Steve parent add vip"), platform.ran, "custom commands are off by default");
|
||||
assertEquals(5, ack[0].getAsJsonArray("done").size());
|
||||
assertEquals(1, ack[0].getAsJsonArray("failed").size());
|
||||
assertTrue(ack[0].getAsJsonArray("failed").get(0).getAsJsonObject().get("error").getAsString().contains("allow-commands"));
|
||||
assertTrue(steve.heard("Well done!"));
|
||||
assertTrue(steve.heard("3x Diamond"));
|
||||
}
|
||||
|
||||
@Test void commandsRunWhenTheOwnerAllowsThem(@TempDir Path dir) {
|
||||
Kit kit = new Kit(dir);
|
||||
Kit.Player steve = kit.platform.add("Steve");
|
||||
Recording platform = new Recording(kit.platform);
|
||||
Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t"));
|
||||
RewardRules r = RewardRules.defaults();
|
||||
RewardPoller poller = new RewardPoller(env, new RewardRules(true, true, r.permissionCommand(), r.permissionDenyCommand(), r.permissionTempCommand(), r.groupCommand()));
|
||||
assertNull(poller.apply(delivery(1, steve.id.toString(), "command", "{\"command\":\"give {player} cake 1\"}")));
|
||||
assertEquals(List.of("give Steve cake 1"), platform.ran);
|
||||
platform.knowsItems = false;
|
||||
assertTrue(poller.apply(delivery(2, steve.id.toString(), "item", "{\"item\":\"mod:thing\",\"amount\":1}")).contains("unknown item"));
|
||||
kit.platform.online.clear();
|
||||
assertEquals("player left", poller.apply(delivery(3, steve.id.toString(), "message", "{\"text\":\"hi\"}")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapRenderTest {
|
||||
// ---- a tiny NBT writer for building chunks ----
|
||||
|
||||
static final class W {
|
||||
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
final DataOutputStream out = new DataOutputStream(bytes);
|
||||
|
||||
W compound(String name) throws IOException { out.writeByte(10); out.writeUTF(name); return this; }
|
||||
W end() throws IOException { out.writeByte(0); return this; }
|
||||
W string(String name, String v) throws IOException { out.writeByte(8); out.writeUTF(name); out.writeUTF(v); return this; }
|
||||
W i(String name, int v) throws IOException { out.writeByte(3); out.writeUTF(name); out.writeInt(v); return this; }
|
||||
W b(String name, int v) throws IOException { out.writeByte(1); out.writeUTF(name); out.writeByte(v); return this; }
|
||||
W list(String name, int type, int n) throws IOException { out.writeByte(9); out.writeUTF(name); out.writeByte(type); out.writeInt(n); return this; }
|
||||
W longs(String name, long[] v) throws IOException {
|
||||
out.writeByte(12); out.writeUTF(name); out.writeInt(v.length);
|
||||
for (long l : v) out.writeLong(l);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** A chunk with one section (y = sectionY) whose blocks come from {@code blockAt}(x,y,z) -> palette index. */
|
||||
static byte[] chunk(int sectionY, List<String> palette, TriFunction blockAt) throws IOException {
|
||||
W w = new W().compound("");
|
||||
w.string("Status", "minecraft:full");
|
||||
w.list("sections", 10, 1);
|
||||
w.b("Y", sectionY);
|
||||
w.compound("block_states");
|
||||
w.list("palette", 10, palette.size());
|
||||
for (String name : palette) w.string("Name", name).end();
|
||||
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1));
|
||||
int per = 64 / bits;
|
||||
long[] data = new long[(4096 + per - 1) / per];
|
||||
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
|
||||
int idx = y << 8 | z << 4 | x;
|
||||
data[idx / per] |= (long) blockAt.apply(x, y, z) << (idx % per * bits);
|
||||
}
|
||||
w.longs("data", data);
|
||||
w.end(); // block_states
|
||||
w.end(); // section
|
||||
w.end(); // root
|
||||
return w.bytes.toByteArray();
|
||||
}
|
||||
|
||||
interface TriFunction { int apply(int x, int y, int z); }
|
||||
|
||||
/** Writes a region file with the given chunks at (cx, cz) -> NBT bytes, zlib compressed. */
|
||||
static Path region(Path dir, Map<Integer, byte[]> chunks) throws IOException {
|
||||
Path file = dir.resolve("r.0.0.mca");
|
||||
ByteBuffer header = ByteBuffer.allocate(8192);
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
int sector = 2;
|
||||
for (var e : chunks.entrySet()) {
|
||||
ByteArrayOutputStream z = new ByteArrayOutputStream();
|
||||
try (DeflaterOutputStream d = new DeflaterOutputStream(z, new Deflater())) { d.write(e.getValue()); }
|
||||
byte[] comp = z.toByteArray();
|
||||
int length = comp.length + 1;
|
||||
int sectors = (length + 4 + 4095) / 4096;
|
||||
header.putInt(e.getKey() * 4, sector << 8 | sectors);
|
||||
header.putInt(4096 + e.getKey() * 4, 1_700_000_000);
|
||||
ByteBuffer rec = ByteBuffer.allocate(sectors * 4096);
|
||||
rec.putInt(length).put((byte) 2).put(comp);
|
||||
body.writeBytes(rec.array());
|
||||
sector += sectors;
|
||||
}
|
||||
Files.createDirectories(dir);
|
||||
try (OutputStream out = Files.newOutputStream(file)) {
|
||||
out.write(header.array());
|
||||
out.write(body.toByteArray());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
@Test void blockNamesBecomeMapColours() {
|
||||
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:air"));
|
||||
assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:glass"));
|
||||
assertEquals(MapColors.GRASS, BlockColors.mapColor("minecraft:grass_block"));
|
||||
assertEquals(MapColors.WATER, BlockColors.mapColor("minecraft:water"));
|
||||
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:stone"));
|
||||
assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:mossy_cobblestone_stairs"));
|
||||
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_planks"));
|
||||
assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_stairs"));
|
||||
assertEquals(MapColors.PLANT, BlockColors.mapColor("minecraft:oak_leaves"));
|
||||
assertEquals(MapColors.SAND, BlockColors.mapColor("minecraft:cut_sandstone_slab"));
|
||||
assertEquals(MapColors.NETHER, BlockColors.mapColor("minecraft:netherrack"));
|
||||
assertEquals(MapColors.RED, BlockColors.mapColor("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1);
|
||||
assertEquals(MapColors.LIGHT_BLUE, BlockColors.mapColor("minecraft:light_blue_concrete"));
|
||||
assertEquals(MapColors.DEEPSLATE, BlockColors.mapColor("minecraft:deepslate_iron_ore"));
|
||||
assertEquals(MapColors.FIRE, BlockColors.mapColor("minecraft:lava"));
|
||||
assertEquals(0, BlockColors.of("minecraft:air"));
|
||||
assertEquals(BlockColors.WATER_COLOR, BlockColors.of("minecraft:water"));
|
||||
int grass = BlockColors.of("minecraft:grass_block");
|
||||
assertTrue((grass >> 8 & 0xFF) > (grass >> 16 & 0xFF) && (grass >> 8 & 0xFF) > (grass & 0xFF), "grass is green");
|
||||
assertEquals(BlockColors.of("minecraft:oak_planks"), BlockColors.of("minecraft:oak_stairs"), "shapes share their material's colour");
|
||||
assertTrue(BlockColors.of("create:brass_casing") != 0, "modded blocks still get a colour");
|
||||
int modded = BlockColors.mapColor("create:brass_casing");
|
||||
assertEquals(modded, BlockColors.mapColor("create:brass_casing"), "stable for unknown blocks");
|
||||
assertTrue(modded > 0);
|
||||
}
|
||||
|
||||
@Test void nbtAndRegionFilesRoundTrip(@TempDir Path dir) throws IOException {
|
||||
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone"), (x, y, z) -> y < 8 ? 1 : 0);
|
||||
Path file = region(dir, Map.of(RegionFile.index(3, 5), nbt));
|
||||
try (RegionFile r = new RegionFile(file)) {
|
||||
assertEquals(1_700_000_000, r.timestamp(RegionFile.index(3, 5)));
|
||||
assertEquals(0, r.timestamp(RegionFile.index(0, 0)));
|
||||
assertNull(r.read(RegionFile.index(0, 0)));
|
||||
Nbt.Compound c = r.read(RegionFile.index(3, 5));
|
||||
assertNotNull(c);
|
||||
assertEquals("minecraft:full", c.string("Status", ""));
|
||||
assertEquals(1, c.list("sections").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void topSurfaceAndWaterDepthAreFound() throws IOException {
|
||||
// y 0..9 stone, 10..12 water in the left half.
|
||||
byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone", "minecraft:water"),
|
||||
(x, y, z) -> y < 6 ? 1 : (x < 8 && y < 9) ? 2 : y < 6 ? 1 : 0);
|
||||
Nbt.Compound c = new Nbt.Compound();
|
||||
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols));
|
||||
assertEquals(BlockColors.of("minecraft:stone"), cols.color[15]);
|
||||
assertEquals(4 * 16 + 5, cols.height[15]);
|
||||
assertEquals(BlockColors.WATER_COLOR, cols.color[0]);
|
||||
assertEquals(4 * 16 + 8, cols.height[0]);
|
||||
assertEquals(3, cols.depth[0]);
|
||||
}
|
||||
|
||||
@Test void theNetherIsShownBeneathItsCeiling() throws IOException {
|
||||
// Section 7 covers y 112..127: bedrock up top (y 124..127 -> local 12..15), open air below, floor of netherrack at local y 3.
|
||||
byte[] nbt = chunk(7, List.of("minecraft:air", "minecraft:bedrock", "minecraft:netherrack"),
|
||||
(x, y, z) -> y >= 12 ? 1 : y <= 3 ? 2 : 0);
|
||||
Nbt.Compound c;
|
||||
try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); }
|
||||
ChunkSampler.Columns cols = new ChunkSampler.Columns();
|
||||
assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols));
|
||||
assertEquals(BlockColors.of("minecraft:netherrack"), cols.color[0], "the floor, not the bedrock roof");
|
||||
assertEquals(7 * 16 + 3, cols.height[0]);
|
||||
}
|
||||
|
||||
@Test void regionsRenderIntoPositionedTiles(@TempDir Path dir) throws IOException {
|
||||
byte[] flat = chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
|
||||
// chunk (17, 2): second tile column (chunk x 16..31), first tile row
|
||||
Path file = region(dir, Map.of(RegionFile.index(17, 2), flat));
|
||||
int[][] tiles = RegionRenderer.render(file, ChunkSampler.Kind.OVERWORLD);
|
||||
assertNotNull(tiles);
|
||||
int[] right = tiles[1];
|
||||
int px = (17 - 16) * 16 + 4, py = 2 * 16 + 4; // inside the chunk
|
||||
assertEquals(0xFF, right[py * 256 + px] >>> 24);
|
||||
int green = right[py * 256 + px];
|
||||
assertTrue((green >> 8 & 0xFF) > (green >> 16 & 0xFF), "grass reads as green");
|
||||
assertEquals(0, tiles[0][py * 256 + px], "other tiles stay empty");
|
||||
assertEquals(0, right[0], "outside the chunk is transparent");
|
||||
}
|
||||
|
||||
@Test void slopesAreShadedAgainstTheNorth() {
|
||||
int n = 512;
|
||||
int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n];
|
||||
Arrays.fill(colors, BlockColors.of("minecraft:stone"));
|
||||
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3);
|
||||
int[] px = RegionRenderer.shade(colors, heights, depths);
|
||||
int uphill = px[16 * n + 3]; // a step up from the north
|
||||
int flat = px[17 * n + 3]; // same height as its north neighbour
|
||||
assertTrue(brightness(uphill) >= brightness(flat));
|
||||
for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 200 - z / 8 * 3);
|
||||
int[] down = RegionRenderer.shade(colors, heights, depths);
|
||||
assertTrue(brightness(down[16 * n + 3]) <= brightness(down[17 * n + 3]));
|
||||
}
|
||||
|
||||
private static int brightness(int argb) { return (argb >> 16 & 0xFF) + (argb >> 8 & 0xFF) + (argb & 0xFF); }
|
||||
|
||||
@Test void pngIsAValidImage() throws IOException {
|
||||
int[] pixels = new int[256 * 256];
|
||||
for (int i = 0; i < pixels.length; i++) pixels[i] = (i % 256 < 128) ? 0xFF336699 : 0;
|
||||
byte[] png = Png.encode(pixels, 256, 256);
|
||||
var image = ImageIO.read(new ByteArrayInputStream(png));
|
||||
assertEquals(256, image.getWidth());
|
||||
assertEquals(0xFF336699, image.getRGB(10, 10));
|
||||
assertEquals(0, image.getRGB(200, 10) >>> 24);
|
||||
assertTrue(png.length < 400, "a two-colour tile is tiny: " + png.length);
|
||||
}
|
||||
|
||||
@Test void realisticTilesStaySmall(@TempDir Path dir) throws IOException {
|
||||
byte[] hills = chunk(0, List.of("minecraft:air", "minecraft:grass_block", "minecraft:stone", "minecraft:water"),
|
||||
(x, y, z) -> y < 3 + (x * 7 + z * 3) % 5 ? 1 : y < 9 && x > 8 ? 3 : 0);
|
||||
Map<Integer, byte[]> chunks = new HashMap<>();
|
||||
for (int i = 0; i < 256; i++) chunks.put(i, hills);
|
||||
int[][] tiles = RegionRenderer.render(region(dir, chunks), ChunkSampler.Kind.OVERWORLD);
|
||||
byte[] png = Png.encode(tiles[0], 256, 256);
|
||||
byte[] rgba = Png.encode(tiles[0], 256, 256);
|
||||
assertTrue(png.length < 20_000, "palette PNG size " + png.length);
|
||||
var image = ImageIO.read(new ByteArrayInputStream(png));
|
||||
for (int i = 0; i < 256 * 256; i += 997) assertEquals(tiles[0][i], image.getRGB(i % 256, i / 256), "pixel " + i);
|
||||
}
|
||||
|
||||
@Test void zoomLevelsAreBuiltFromTheTilesBelow(@TempDir Path dir) throws IOException {
|
||||
TileStore store = new TileStore(dir);
|
||||
int[] red = new int[256 * 256];
|
||||
Arrays.fill(red, 0xFFFF0000);
|
||||
TileStore.Key k = new TileStore.Key("overworld", 0, 3, -2);
|
||||
assertTrue(store.update(k, red));
|
||||
assertFalse(store.update(k, red.clone()), "an identical tile is not a change");
|
||||
TileStore.Key parent = store.rebuildParent(k);
|
||||
assertEquals(new TileStore.Key("overworld", 1, 1, -1), parent);
|
||||
int[] p = store.load(parent);
|
||||
// child (3,-2) is the lower-right?? x=3 odd -> right, y=-2 even -> top: quadrant (1,0)
|
||||
assertEquals(0xFFFF0000, p[10 * 256 + 200]);
|
||||
assertEquals(0, p[10 * 256 + 10]);
|
||||
assertEquals(parent, TileStore.Key.parse(parent.toString()));
|
||||
// a fresh store sees the cached tile
|
||||
assertArrayEquals(red, new TileStore(dir).load(k));
|
||||
store.clear();
|
||||
assertNull(new TileStore(dir).load(k));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import net.scopenet.integration.PanelClient;
|
||||
import net.scopenet.integration.Settings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** A fake panel and a generated world: tiles, players and the overlay all arrive, and only once. */
|
||||
class WorldMapSyncTest {
|
||||
@Test void worldsAreRenderedAndUploaded(@TempDir Path dir) throws Exception {
|
||||
byte[] flat = MapRenderTest.chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
|
||||
MapRenderTest.region(dir.resolve("world/region"), Map.of(RegionFile.index(1, 1), flat, RegionFile.index(20, 3), flat));
|
||||
|
||||
List<String> tiles = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> players = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> overlays = new CopyOnWriteArrayList<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/server/v1/map/config", ex -> reply(ex, "{\"enabled\":true,\"epoch\":3,\"max_zoom\":2,\"player_interval_ms\":500}"));
|
||||
server.createContext("/api/server/v1/map/tiles", ex -> {
|
||||
assertEquals("dim=minecraft:overworld", ex.getRequestURI().getRawQuery());
|
||||
ByteBuffer body = ByteBuffer.wrap(ex.getRequestBody().readAllBytes());
|
||||
while (body.hasRemaining()) {
|
||||
int zoom = body.get() & 0xFF, x = body.getInt(), y = body.getInt(), len = body.getInt();
|
||||
byte[] png = new byte[len];
|
||||
body.get(png);
|
||||
assertEquals((byte) 0x89, png[0]);
|
||||
tiles.add(zoom + "/" + x + "/" + y);
|
||||
}
|
||||
reply(ex, "{\"stored\":1}");
|
||||
});
|
||||
server.createContext("/api/server/v1/map/players", ex -> { players.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.createContext("/api/server/v1/map/overlay", ex -> { overlays.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.start();
|
||||
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
|
||||
WorldMapSync sync = new WorldMapSync(client, dir.resolve("world"), dir.resolve("cache"), System.err::println);
|
||||
sync.setOverlay(() -> "{\"claims\":[],\"pins\":[{\"id\":\"spawn\"}]}");
|
||||
sync.offerPlayers(List.of(new WorldMapSync.Player("11111111-1111-1111-1111-111111111111", "Steve", "minecraft:overworld", 10.5, 64, 20, 90, 0)));
|
||||
try {
|
||||
sync.start();
|
||||
long deadline = System.currentTimeMillis() + 20_000;
|
||||
while ((tiles.size() < 4 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100);
|
||||
// chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them
|
||||
assertTrue(tiles.contains("0/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("0/1/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("1/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("2/0/0"), tiles.toString());
|
||||
assertEquals("Steve", players.get(0).getAsJsonArray("players").get(0).getAsJsonObject().get("name").getAsString());
|
||||
assertEquals("spawn", overlays.get(0).getAsJsonArray("pins").get(0).getAsJsonObject().get("id").getAsString());
|
||||
int sent = tiles.size();
|
||||
Thread.sleep(1500);
|
||||
assertEquals(sent, tiles.size(), "an unchanged world isn't uploaded again");
|
||||
} finally {
|
||||
sync.close();
|
||||
server.stop(0);
|
||||
}
|
||||
// the state survives a restart: a new sync with the same cache sends nothing
|
||||
assertTrue(java.nio.file.Files.isRegularFile(dir.resolve("cache/state.json")));
|
||||
}
|
||||
|
||||
private static void reply(com.sun.net.httpserver.HttpExchange ex, String json) throws java.io.IOException {
|
||||
byte[] b = json.getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, b.length);
|
||||
ex.getResponseBody().write(b);
|
||||
ex.close();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public final class ClientState {
|
||||
public volatile List<ShopItem> shop = List.of();
|
||||
public volatile Held held;
|
||||
public volatile long marketAt, shopAt;
|
||||
/** A window the server asked us to open ("market" or "shop"), taken once by the screen code. */
|
||||
public volatile String pendingOpen;
|
||||
|
||||
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
|
||||
public String[] handle(String json) {
|
||||
@@ -94,6 +96,7 @@ public final class ClientState {
|
||||
shopAt = System.currentTimeMillis();
|
||||
}
|
||||
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
|
||||
case "open" -> pendingOpen = str(m, "screen");
|
||||
default -> { }
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -23,7 +23,16 @@ public final class Link {
|
||||
void register() {
|
||||
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
|
||||
String json = buf.readUtf(32767 * 3);
|
||||
client.execute(() -> toast(state.handle(json)));
|
||||
client.execute(() -> {
|
||||
toast(state.handle(json));
|
||||
String open = state.pendingOpen;
|
||||
state.pendingOpen = null;
|
||||
// A right-clicked shop point opens its window, unless another screen is already up.
|
||||
if (open != null && config.enabled && client.screen == null) {
|
||||
if (open.equals("market")) client.setScreen(new net.scopenet.client.screen.MarketScreen());
|
||||
else if (open.equals("shop")) client.setScreen(new net.scopenet.client.screen.ShopScreen());
|
||||
}
|
||||
});
|
||||
});
|
||||
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
|
||||
|
||||
+12
-1
@@ -33,8 +33,12 @@ final class FabricConfig {
|
||||
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
|
||||
|
||||
EssentialsConfig essentials() {
|
||||
java.util.Map<String, Integer> cooldowns = new java.util.HashMap<>();
|
||||
for (String cmd : new String[]{"spawn", "home", "back", "tpa", "rtp", "warp"}) cooldowns.put(cmd, Math.max(0, integer("essentials.cooldown." + cmd, 0)));
|
||||
return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)),
|
||||
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15);
|
||||
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, Math.max(1, integer("essentials.rtp_attempts", 15)),
|
||||
Math.max(0, integer("essentials.rtp_min_radius", 0)), Math.max(0, integer("essentials.max_warps", 0)),
|
||||
Math.max(1, integer("essentials.home_name_max_length", 32)), cooldowns);
|
||||
}
|
||||
|
||||
Features features(Settings s) {
|
||||
@@ -42,6 +46,13 @@ final class FabricConfig {
|
||||
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
|
||||
}
|
||||
|
||||
net.scopenet.core.RewardRules rewards() {
|
||||
net.scopenet.core.RewardRules d = net.scopenet.core.RewardRules.defaults();
|
||||
return new net.scopenet.core.RewardRules(bool("rewards.enabled", true), bool("rewards.allow_commands", false),
|
||||
text("rewards.permission_command", d.permissionCommand()), text("rewards.permission_deny_command", d.permissionDenyCommand()),
|
||||
text("rewards.permission_temp_command", d.permissionTempCommand()), text("rewards.group_command", d.groupCommand()));
|
||||
}
|
||||
|
||||
/** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */
|
||||
boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); }
|
||||
}
|
||||
+29
@@ -15,12 +15,17 @@ import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.scopenet.core.*;
|
||||
import net.scopenet.core.map.MapService;
|
||||
import net.scopenet.fabric.api.ScopenetEconomy;
|
||||
import net.scopenet.integration.Integration;
|
||||
import net.scopenet.integration.Settings;
|
||||
@@ -49,6 +54,9 @@ public final class FabricFeatures implements Runnable {
|
||||
private ClientLink link;
|
||||
private Perms perms;
|
||||
private FabricConfig config;
|
||||
private ActionPoller poller;
|
||||
private net.scopenet.core.RewardPoller rewards;
|
||||
private MapService map;
|
||||
private int ticks;
|
||||
private String placeholderStatus = "off";
|
||||
|
||||
@@ -64,6 +72,17 @@ public final class FabricFeatures implements Runnable {
|
||||
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
|
||||
return true;
|
||||
});
|
||||
// Right-clicking a shop point opens the market or the shop.
|
||||
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
|
||||
if (level.isClientSide || hand != InteractionHand.MAIN_HAND || !(player instanceof ServerPlayer sp) || commands == null) return InteractionResult.PASS;
|
||||
BlockPos at = hit.getBlockPos();
|
||||
Optional<ShopPoints.Shop> shop = commands.shops.at(level.dimension().location().toString(), at.getX(), at.getY(), at.getZ());
|
||||
if (shop.isEmpty()) return InteractionResult.PASS;
|
||||
CorePlayer p = platform.wrap(sp);
|
||||
String kind = shop.get().kind().equals("market") ? "market" : "shop";
|
||||
if (!link.open(p, kind)) commands.run(kind, p);
|
||||
return InteractionResult.SUCCESS;
|
||||
});
|
||||
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
|
||||
byte[] data = new byte[buf.readableBytes()];
|
||||
buf.readBytes(data);
|
||||
@@ -93,6 +112,12 @@ public final class FabricFeatures implements Runnable {
|
||||
integration.onNotifications(events -> link.onPanelEvents(events));
|
||||
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
|
||||
|
||||
// Actions from the map, and the claims and pins the SCOPENET Map draws.
|
||||
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
|
||||
rewards = new net.scopenet.core.RewardPoller(env, config.rewards());
|
||||
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
|
||||
config.bool("map.show_homes", false), s.getMotd());
|
||||
integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); });
|
||||
registerCommands(s.getCommands().getDispatcher());
|
||||
registerPlaceholders();
|
||||
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
|
||||
@@ -105,6 +130,7 @@ public final class FabricFeatures implements Runnable {
|
||||
Bridge.claimBypass = p -> false;
|
||||
if (platform != null) platform.shutdown();
|
||||
commands = null;
|
||||
map = null;
|
||||
}
|
||||
|
||||
private void registerPlaceholders() {
|
||||
@@ -125,6 +151,9 @@ public final class FabricFeatures implements Runnable {
|
||||
if (ticks % 20 == 0) {
|
||||
cache.tick(p -> link.pushState(p));
|
||||
link.tick();
|
||||
poller.tick();
|
||||
rewards.tick();
|
||||
map.tick();
|
||||
}
|
||||
if (ticks % 40 == 0) commands.tick();
|
||||
}
|
||||
|
||||
+22
@@ -72,4 +72,26 @@ final class FabricPlatform implements Platform {
|
||||
@Override public void runMain(Runnable task) { server.execute(task); }
|
||||
|
||||
@Override public void runAsync(Runnable task) { io.execute(task); }
|
||||
|
||||
@Override public boolean giveItem(UUID id, String item, int amount) {
|
||||
ServerPlayer p = server.getPlayerList().getPlayer(id);
|
||||
ResourceLocation location = ResourceLocation.tryParse(item);
|
||||
if (p == null || location == null) return false;
|
||||
var found = net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(location);
|
||||
if (found.isEmpty() || found.get() == net.minecraft.world.item.Items.AIR) return false;
|
||||
net.minecraft.world.item.Item type = found.get();
|
||||
for (int left = amount; left > 0; ) {
|
||||
int n = Math.min(left, type.getMaxStackSize());
|
||||
net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(type, n);
|
||||
p.getInventory().add(stack);
|
||||
if (!stack.isEmpty()) p.drop(stack, false);
|
||||
left -= n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public boolean console(String command) {
|
||||
server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,11 @@ final class Perms {
|
||||
|
||||
static boolean luckPermsPresent() { return LUCKPERMS; }
|
||||
|
||||
/** Nodes that grant extras (limit tiers like scopenet.homes.10): nobody has them unless a permissions system says so. */
|
||||
static boolean isGrantNode(String node) { return node.startsWith("scopenet.homes.") || node.startsWith("scopenet.limit."); }
|
||||
|
||||
static boolean isAdminNode(String node) {
|
||||
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
|
||||
return node.equals("scopenet.claims.bypass") || node.equals("scopenet.cooldown.bypass") || node.startsWith("scopenet.admin")
|
||||
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ final class Perms {
|
||||
// LuckPerms not ready yet or an API change: fall back to the defaults below.
|
||||
}
|
||||
}
|
||||
if (isGrantNode(node)) return false;
|
||||
if (isAdminNode(node)) return player.hasPermissions(2);
|
||||
return everyoneByDefault || player.hasPermissions(2);
|
||||
}
|
||||
|
||||
@@ -33,24 +33,13 @@ public final class Bridge {
|
||||
}));
|
||||
integration.startClaimSync();
|
||||
Path config = Path.of("config/scopenet.properties");
|
||||
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder());
|
||||
if (Settings.mapEnabled(config)) integration.enableMap(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT), Path.of("config/scopenet/map"));
|
||||
} catch (Exception e) {
|
||||
// Throwing aborts startup; silently disabling an auth mod is unsafe.
|
||||
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The level folder: `level-name` from server.properties, like the game itself reads it. */
|
||||
private static Path worldFolder() {
|
||||
Properties props = new Properties();
|
||||
try (var in = java.nio.file.Files.newBufferedReader(Path.of("server.properties"))) {
|
||||
props.load(in);
|
||||
} catch (java.io.IOException ignored) {
|
||||
// Fall back to the default level name.
|
||||
}
|
||||
return Path.of(props.getProperty("level-name", "world"));
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
|
||||
Integration current = integration;
|
||||
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
|
||||
@@ -82,13 +71,13 @@ public final class Bridge {
|
||||
current.tick();
|
||||
if (++mapTicks >= 20) {
|
||||
mapTicks = 0;
|
||||
List<LiveMapSync.Player> positions = new ArrayList<>();
|
||||
List<net.scopenet.worldmap.WorldMapSync.Player> positions = new ArrayList<>();
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
|
||||
positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
|
||||
Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0,
|
||||
Math.round(player.getYRot()), Math.round(player.getXRot())));
|
||||
}
|
||||
current.liveMapPlayers(positions);
|
||||
current.mapPlayers(positions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,6 @@ jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from project(':common').sourceSets.main.output
|
||||
from project(':api').sourceSets.main.output
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') }.collect { zipTree(it) } }
|
||||
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package net.scopenet.paper;
|
||||
|
||||
import net.scopenet.api.ScopenetPlayer;
|
||||
import net.scopenet.paper.compat.ScopenetApiImpl;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Formats player chat from the cached SCOPENET profile without a network call on the chat thread. */
|
||||
public final class ChatFormatter {
|
||||
private static final Pattern MARKDOWN = Pattern.compile("(\\*\\*(.+?)\\*\\*|__(.+?)__|~~(.+?)~~|`(.+?)`|\\*(.+?)\\*)");
|
||||
private static final Pattern HEX = Pattern.compile("&#([0-9a-fA-F]{6})");
|
||||
private final ScopenetApiImpl profiles;
|
||||
|
||||
public ChatFormatter(ScopenetApiImpl profiles) { this.profiles = profiles; }
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null ? "" : ChatColor.stripColor(value).replace('%', ' ').replace('[', ' ').replace(']', ' ').trim();
|
||||
}
|
||||
|
||||
public String format(Player player) {
|
||||
ScopenetPlayer p = profiles.peek(player.getUniqueId()).orElse(null);
|
||||
String guild = p == null || p.guild() == null ? "" : "§3[" + clean(p.guild().tag()) + "] ";
|
||||
String title = p == null || p.rankTitle() == null || p.rankTitle().isBlank() ? "Novice" : clean(p.rankTitle());
|
||||
String group = p == null || p.rankGroup() == null || p.rankGroup().isBlank() ? "Member"
|
||||
: p.rankDisplay() == null || p.rankDisplay().isBlank() ? clean(p.rankGroup()) : clean(p.rankDisplay());
|
||||
String prefix = p == null ? "" : p.rankPrefix();
|
||||
String suffix = p == null ? "" : p.rankSuffix();
|
||||
String decorated = prefix == null || prefix.isBlank() ? "" : ChatColor.translateAlternateColorCodes('&', prefix.replace("%", "%%")) + " ";
|
||||
String name = player.getDisplayName().replace("%", "%%");
|
||||
String tail = suffix == null || suffix.isBlank() ? "" : " " + ChatColor.translateAlternateColorCodes('&', suffix.replace("%", "%%"));
|
||||
return guild + "§6[" + title + "] §b[" + group + "] §r" + decorated + name + tail + "§7: §f%2$s";
|
||||
}
|
||||
|
||||
public String message(Player player, String raw) {
|
||||
String input = player.hasPermission("scopenet.chat.color") ? ChatColor.translateAlternateColorCodes('&', raw) : ChatColor.stripColor(raw);
|
||||
if (player.hasPermission("scopenet.chat.color")) {
|
||||
Matcher hex = HEX.matcher(input);
|
||||
StringBuffer colored = new StringBuffer();
|
||||
while (hex.find()) hex.appendReplacement(colored, Matcher.quoteReplacement(net.md_5.bungee.api.ChatColor.of('#' + hex.group(1)).toString()));
|
||||
hex.appendTail(colored);
|
||||
input = colored.toString();
|
||||
}
|
||||
Matcher matcher = MARKDOWN.matcher(input);
|
||||
StringBuffer out = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String code = matcher.group(2) != null ? "§l" : matcher.group(3) != null ? "§n" : matcher.group(4) != null ? "§m" : matcher.group(5) != null ? "§7" : "§o";
|
||||
String text = matcher.group(2) != null ? matcher.group(2) : matcher.group(3) != null ? matcher.group(3)
|
||||
: matcher.group(4) != null ? matcher.group(4) : matcher.group(5) != null ? matcher.group(5) : matcher.group(6);
|
||||
matcher.appendReplacement(out, Matcher.quoteReplacement(code + text + "§r§f"));
|
||||
}
|
||||
matcher.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package net.scopenet.paper;
|
||||
|
||||
import net.scopenet.core.*;
|
||||
import net.scopenet.core.map.MapService;
|
||||
import net.scopenet.core.map.MapSource;
|
||||
import net.scopenet.paper.commands.EconomyHandler;
|
||||
import net.scopenet.paper.commands.EssentialsHandler;
|
||||
import net.scopenet.paper.commands.GuildHandler;
|
||||
import net.scopenet.integration.Integration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
@@ -22,9 +27,16 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
private final PlayerCache cache;
|
||||
private final ClientLink link;
|
||||
private final Placeholders placeholders;
|
||||
private final Integration integration;
|
||||
private final ShopPoints shops;
|
||||
private ActionPoller poller;
|
||||
private net.scopenet.core.RewardPoller rewards;
|
||||
private MapService mapService;
|
||||
private EconomyHandler economy;
|
||||
|
||||
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
|
||||
this.plugin = plugin;
|
||||
this.integration = integration;
|
||||
PaperPlatform platform = new PaperPlatform(plugin);
|
||||
Features features = new Features(true, plugin.getConfig().getBoolean("economy.enabled", true), integration.settings().guildsEnabled(),
|
||||
integration.settings().socialEnabled(), integration.settings().landClaimingEnabled(),
|
||||
@@ -33,6 +45,7 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
env = new Env(platform, new PanelAdapter(integration), features, Path.of(plugin.getDataFolder().getPath()), System::currentTimeMillis, new Random(), log);
|
||||
cache = new PlayerCache(env);
|
||||
placeholders = new Placeholders(env, cache);
|
||||
shops = new ShopPoints(Path.of(plugin.getDataFolder().getPath()).resolve("shops.json"));
|
||||
link = new ClientLink(env, cache, Bukkit.getServer().getName(), (p, bytes) -> {
|
||||
Player target = Bukkit.getPlayer(p.uuid());
|
||||
if (target != null) target.sendPluginMessage(plugin, Wire.S2C, bytes);
|
||||
@@ -50,6 +63,59 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
}, 40, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop points, actions from the map, and the claims and pins for the SCOPENET Map. Called once the command handlers exist; any of them may be
|
||||
* null when that system is switched off.
|
||||
*/
|
||||
void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) {
|
||||
this.economy = economy;
|
||||
Bukkit.getPluginManager().registerEvents(new ShopListener(), plugin);
|
||||
poller = new ActionPoller(env, (from, to) -> {
|
||||
Player a = Bukkit.getPlayer(from.uuid()), b = Bukkit.getPlayer(to.uuid());
|
||||
if (a != null && b != null && essentials != null) essentials.requestTpa(a, b);
|
||||
});
|
||||
MapSource source = new MapSource() {
|
||||
@Override public Map<String, Pos> warps() { return essentials == null ? Map.of() : essentials.warpsForMap(); }
|
||||
@Override public Map<UUID, Map<String, Pos>> homes() { return essentials == null ? Map.of() : essentials.homesForMap(); }
|
||||
@Override public Map<String, Pos> guildHomes() { return guilds == null ? Map.of() : guilds.guildHomesForMap(); }
|
||||
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
|
||||
};
|
||||
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
|
||||
plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName());
|
||||
integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString());
|
||||
var cfg = plugin.getConfig();
|
||||
var defaults = net.scopenet.core.RewardRules.defaults();
|
||||
rewards = new net.scopenet.core.RewardPoller(env, new net.scopenet.core.RewardRules(cfg.getBoolean("rewards.enabled", true), cfg.getBoolean("rewards.allow-commands", false),
|
||||
cfg.getString("rewards.permission-command", defaults.permissionCommand()), cfg.getString("rewards.permission-deny-command", defaults.permissionDenyCommand()),
|
||||
cfg.getString("rewards.permission-temp-command", defaults.permissionTempCommand()), cfg.getString("rewards.group-command", defaults.groupCommand())));
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); rewards.tick(); mapService.tick(); }, 60, 20);
|
||||
}
|
||||
|
||||
/** /market point add|remove|list, on the block the player is looking at (or standing on). */
|
||||
void shopCommand(Player player, String[] args) {
|
||||
org.bukkit.block.Block looked = player.getTargetBlockExact(6);
|
||||
org.bukkit.Location l = looked != null ? looked.getLocation() : player.getLocation().subtract(0, 1, 0);
|
||||
shops.command(new PaperPlatform.PaperPlayer(player), args, new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getBlockX(), l.getBlockY(), l.getBlockZ(), 0, 0));
|
||||
}
|
||||
|
||||
/** Right-clicking a shop point opens the market or shop: the client mod's window if they have it, otherwise the chest GUI. */
|
||||
private final class ShopListener implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(ignoreCancelled = true)
|
||||
public void onInteract(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK || event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) return;
|
||||
org.bukkit.block.Block block = event.getClickedBlock();
|
||||
if (block == null) return;
|
||||
Optional<ShopPoints.Shop> shop = shops.at(ScopenetPlugin.dimension(block.getWorld()), block.getX(), block.getY(), block.getZ());
|
||||
if (shop.isEmpty() || economy == null) return;
|
||||
event.setCancelled(true);
|
||||
Player player = event.getPlayer();
|
||||
String kind = shop.get().kind().equals("market") ? "market" : "shop";
|
||||
if (!link.open(new PaperPlatform.PaperPlayer(player), kind)) {
|
||||
if (kind.equals("market")) economy.openMarket(player); else economy.openShop(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
|
||||
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
|
||||
@@ -78,12 +144,34 @@ final class ClientLinkBridge implements PluginMessageListener {
|
||||
return out;
|
||||
}
|
||||
@Override public Optional<Pos> safeSurface(String world, int x, int z) { return Optional.empty(); }
|
||||
@Override public Pos worldSpawn(String world) { return new Pos(world, 0, 64, 0, 0, 0); }
|
||||
@Override public Pos worldSpawn(String world) {
|
||||
for (org.bukkit.World w : Bukkit.getWorlds()) {
|
||||
if (ScopenetPlugin.dimension(w).equals(world)) { org.bukkit.Location l = w.getSpawnLocation(); return new Pos(world, l.getX(), l.getY(), l.getZ(), 0, 0); }
|
||||
}
|
||||
org.bukkit.Location l = Bukkit.getWorlds().get(0).getSpawnLocation();
|
||||
return new Pos(ScopenetPlugin.dimension(Bukkit.getWorlds().get(0)), l.getX(), l.getY(), l.getZ(), 0, 0);
|
||||
}
|
||||
@Override public void runMain(Runnable task) {
|
||||
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
|
||||
}
|
||||
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
|
||||
|
||||
@Override public boolean giveItem(UUID id, String item, int amount) {
|
||||
Player p = Bukkit.getPlayer(id);
|
||||
if (p == null) return false;
|
||||
Material m = Material.matchMaterial(item);
|
||||
if (m == null && item.startsWith("minecraft:")) m = Material.matchMaterial(item.substring(10));
|
||||
if (m == null || !m.isItem() || m == Material.AIR) return false;
|
||||
for (int left = amount; left > 0; ) {
|
||||
int n = Math.min(left, m.getMaxStackSize());
|
||||
for (ItemStack over : p.getInventory().addItem(new ItemStack(m, n)).values()) p.getWorld().dropItemNaturally(p.getLocation(), over);
|
||||
left -= n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public boolean console(String command) { return Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); }
|
||||
|
||||
record PaperPlayer(Player p) implements CorePlayer {
|
||||
@Override public UUID uuid() { return p.getUniqueId(); }
|
||||
@Override public String name() { return p.getName(); }
|
||||
|
||||
@@ -25,6 +25,9 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
private ScopenetApiImpl api;
|
||||
private CompatManager compat;
|
||||
private ClientLinkBridge clientLink;
|
||||
private EssentialsHandler essentialsHandler;
|
||||
private GuildHandler guildHandler;
|
||||
private ChatFormatter chatFormatter;
|
||||
|
||||
@Override public void onEnable() {
|
||||
// Install the deny-by-default listener even if configuration is invalid.
|
||||
@@ -48,15 +51,25 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
// Claim protection runs from a local copy of the panel's claims, refreshed in the background.
|
||||
integration.startClaimSync();
|
||||
|
||||
// Central Live Map: mirror chunks and player positions to the panel.
|
||||
if (getConfig().getBoolean("livemap.enabled", true)) {
|
||||
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath());
|
||||
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
|
||||
// The SCOPENET Map: renders the world to tiles and sends them, positions, claims and pins to the panel.
|
||||
// Started on the first tick so the worlds exist.
|
||||
if (getConfig().getBoolean("map.enabled", getConfig().getBoolean("livemap.enabled", true))) {
|
||||
Bukkit.getScheduler().runTask(this, () -> {
|
||||
if (integration == null || Bukkit.getWorlds().isEmpty()) return;
|
||||
integration.enableMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), getDataFolder().toPath().resolve("map"));
|
||||
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
|
||||
});
|
||||
getServer().getPluginManager().registerEvents(new org.bukkit.event.Listener() {
|
||||
@org.bukkit.event.EventHandler public void onSave(org.bukkit.event.world.WorldSaveEvent event) {
|
||||
if (integration != null) integration.mapSaved();
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
|
||||
// and receive level-ups, achievements and guild changes as ordinary Bukkit events.
|
||||
api = new ScopenetApiImpl(integration.client());
|
||||
chatFormatter = new ChatFormatter(api);
|
||||
ScopenetApiProvider.register(api);
|
||||
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
|
||||
// Optional client mod (HUD, claim overlay, shop and market windows).
|
||||
@@ -78,10 +91,18 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
// Register Essentials commands & GUI
|
||||
{
|
||||
EssentialsHandler essentials = new EssentialsHandler(this);
|
||||
essentialsHandler = essentials;
|
||||
getServer().getPluginManager().registerEvents(essentials, this);
|
||||
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"};
|
||||
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "reqwarp", "warprequests", "warpapprove", "warpdeny", "setwarp", "delwarp", "playtime"};
|
||||
for (String c : essCmds) bindCommand(c, essentials);
|
||||
}
|
||||
HandHandler hand = new HandHandler(this);
|
||||
getServer().getPluginManager().registerEvents(hand, this);
|
||||
bindCommand("hand", hand);
|
||||
bindCommand("handview", hand);
|
||||
NicknameHandler nick = new NicknameHandler(this);
|
||||
getServer().getPluginManager().registerEvents(nick, this);
|
||||
bindCommand("nick", nick);
|
||||
|
||||
// Register Economy commands & GUI
|
||||
{
|
||||
@@ -94,12 +115,17 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
// Register Guilds & Land Claims commands & GUI
|
||||
{
|
||||
GuildHandler guilds = new GuildHandler(this, integration, economy);
|
||||
guildHandler = guilds;
|
||||
getServer().getPluginManager().registerEvents(guilds, this);
|
||||
bindCommand("guild", guilds);
|
||||
bindCommand("claim", guilds);
|
||||
bindCommand("unclaim", guilds);
|
||||
bindCommand("autoclaim", guilds);
|
||||
}
|
||||
|
||||
// Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws.
|
||||
clientLink.startMap(essentialsHandler, guildHandler, economy);
|
||||
|
||||
// Reloading an auth plugin cannot silently admit existing sessions.
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
|
||||
getLogger().info("SCOPENET integration initialized. Configured modules:");
|
||||
@@ -111,7 +137,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED"));
|
||||
getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED"));
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
|
||||
getLogger().log(java.util.logging.Level.SEVERE, "SCOPENET is blocking joins", e);
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -143,7 +169,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
|
||||
private void snapshotPlayers() {
|
||||
if (integration == null) return;
|
||||
java.util.List<LiveMapSync.Player> list = new java.util.ArrayList<>();
|
||||
java.util.List<net.scopenet.worldmap.WorldMapSync.Player> list = new java.util.ArrayList<>();
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
Location at = p.getLocation();
|
||||
String dim = switch (at.getWorld().getEnvironment()) {
|
||||
@@ -152,22 +178,22 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
default -> "minecraft:overworld";
|
||||
};
|
||||
// Rounded so an idle roster doesn't look "changed" on every tick.
|
||||
list.add(new LiveMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
|
||||
list.add(new net.scopenet.worldmap.WorldMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
|
||||
Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0,
|
||||
Math.round(at.getYaw()), Math.round(at.getPitch())));
|
||||
}
|
||||
integration.liveMapPlayers(list);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void worldSaved(org.bukkit.event.world.WorldSaveEvent event) {
|
||||
if (integration != null) integration.liveMapSaved();
|
||||
integration.mapPlayers(list);
|
||||
}
|
||||
|
||||
public static String dimension(World world) {
|
||||
return world.getKey().toString();
|
||||
}
|
||||
|
||||
/** {@code /market point ...}: manage shop points (blocks that open the market or shop when right-clicked). */
|
||||
public void shopCommand(Player player, String[] args) {
|
||||
if (clientLink != null) clientLink.shopCommand(player, args);
|
||||
}
|
||||
|
||||
private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) {
|
||||
org.bukkit.command.PluginCommand cmd = getCommand(name);
|
||||
if (cmd != null) {
|
||||
@@ -345,8 +371,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
if (claimed(event.getBlock())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void chat(AsyncPlayerChatEvent event) {
|
||||
if (integration != null && integration.settings().socialEnabled() && integration.settings().chatPrefixesEnabled() && chatFormatter != null) {
|
||||
event.setFormat(chatFormatter.format(event.getPlayer()));
|
||||
event.setMessage(chatFormatter.message(event.getPlayer(), event.getMessage()));
|
||||
}
|
||||
add(event.getPlayer(), "messages");
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Open the player market window (used by physical shop points). */
|
||||
public void openMarket(Player player) { handleMarket(player, new String[0], false); }
|
||||
|
||||
/** Open the server shop window (used by physical shop points). */
|
||||
public void openShop(Player player) { handleShop(player); }
|
||||
|
||||
private void handleBalance(Player player) {
|
||||
player.sendMessage(ChatColor.GRAY + "Fetching your balance...");
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
@@ -269,6 +275,10 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
||||
|
||||
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
|
||||
void handleMarket(Player player, String[] args, boolean asGuild) {
|
||||
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
|
||||
plugin.shopCommand(player, java.util.Arrays.copyOfRange(args, 1, args.length));
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
|
||||
if (!asGuild && !player.hasPermission("scopenet.command.market.buy")) {
|
||||
player.sendMessage(ChatColor.RED + "You do not have permission to buy from the market.");
|
||||
|
||||
+169
-6
@@ -27,8 +27,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
private final ScopenetPlugin plugin;
|
||||
private final File homesFile;
|
||||
private final File warpsFile;
|
||||
private final File warpRequestsFile;
|
||||
private final YamlConfiguration homesConfig;
|
||||
private final YamlConfiguration warpsConfig;
|
||||
private final YamlConfiguration warpRequestsConfig;
|
||||
|
||||
private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>();
|
||||
private final Map<String, Location> warps = new ConcurrentHashMap<>();
|
||||
@@ -43,8 +45,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
this.plugin = plugin;
|
||||
this.homesFile = new File(plugin.getDataFolder(), "homes.yml");
|
||||
this.warpsFile = new File(plugin.getDataFolder(), "warps.yml");
|
||||
this.warpRequestsFile = new File(plugin.getDataFolder(), "warp_requests.yml");
|
||||
this.homesConfig = YamlConfiguration.loadConfiguration(homesFile);
|
||||
this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile);
|
||||
this.warpRequestsConfig = YamlConfiguration.loadConfiguration(warpRequestsFile);
|
||||
loadHomes();
|
||||
loadWarps();
|
||||
}
|
||||
@@ -100,6 +104,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private void saveWarpRequests() {
|
||||
try { warpRequestsConfig.save(warpRequestsFile); }
|
||||
catch (IOException e) { plugin.getLogger().warning("Failed to save warp requests: " + e.getMessage()); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) {
|
||||
if (event.getFrom() != null && event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) {
|
||||
@@ -124,7 +133,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
Map<String, Location> playerHomes = homes.get(player.getUniqueId());
|
||||
if (playerHomes != null && playerHomes.containsKey(homeName)) {
|
||||
player.closeInventory();
|
||||
if (coolingDown(player, "home")) return;
|
||||
player.teleport(playerHomes.get(homeName));
|
||||
startCooldown(player, "home");
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName);
|
||||
}
|
||||
} else if (title.equals(GUI_WARPS_TITLE)) {
|
||||
@@ -134,7 +145,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase();
|
||||
if (warps.containsKey(warpName)) {
|
||||
player.closeInventory();
|
||||
if (coolingDown(player, "warp")) return;
|
||||
player.teleport(warps.get(warpName));
|
||||
startCooldown(player, "warp");
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +162,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
|
||||
if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; }
|
||||
String cmd = command.getName().toLowerCase();
|
||||
boolean cooled = cooldownSeconds(cmd) > 0 && !player.hasPermission("scopenet.cooldown.bypass");
|
||||
if (cooled && coolingDown(player, cmd)) return true;
|
||||
Location before = player.getLocation();
|
||||
switch (cmd) {
|
||||
case "spawn" -> handleSpawn(player);
|
||||
case "home" -> handleHome(player, args);
|
||||
@@ -160,9 +176,16 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
case "tpdeny" -> handleTpDeny(player);
|
||||
case "rtp" -> handleRtp(player);
|
||||
case "warp" -> handleWarp(player, args);
|
||||
case "reqwarp" -> requestWarp(player, args);
|
||||
case "warprequests" -> listWarpRequests(player);
|
||||
case "warpapprove" -> reviewWarp(player, args, true);
|
||||
case "warpdeny" -> reviewWarp(player, args, false);
|
||||
case "setwarp" -> setWarp(player, args);
|
||||
case "delwarp" -> deleteWarp(player, args);
|
||||
case "playtime" -> handlePlaytime(player, args);
|
||||
default -> { return false; }
|
||||
}
|
||||
if (cooled && ((cmd.equals("tpa") && args.length > 0) || cmd.equals("rtp") || !before.equals(player.getLocation()))) startCooldown(player, cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -172,6 +195,28 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to spawn.");
|
||||
}
|
||||
|
||||
private static net.scopenet.core.Pos toPos(Location l) {
|
||||
return new net.scopenet.core.Pos(l.getWorld() == null ? "minecraft:overworld" : ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
|
||||
}
|
||||
|
||||
/** Warps as shown on the map. A copy. */
|
||||
public Map<String, net.scopenet.core.Pos> warpsForMap() {
|
||||
Map<String, net.scopenet.core.Pos> out = new TreeMap<>();
|
||||
warps.forEach((name, loc) -> { if (loc.getWorld() != null) out.put(name, toPos(loc)); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every player's homes, for the map (only shown if the admin allows it). A copy. */
|
||||
public Map<UUID, Map<String, net.scopenet.core.Pos>> homesForMap() {
|
||||
Map<UUID, Map<String, net.scopenet.core.Pos>> out = new HashMap<>();
|
||||
homes.forEach((id, byName) -> {
|
||||
Map<String, net.scopenet.core.Pos> mine = new TreeMap<>();
|
||||
byName.forEach((name, loc) -> { if (loc.getWorld() != null) mine.put(name, toPos(loc)); });
|
||||
out.put(id, mine);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
private void handleHome(Player player, String[] args) {
|
||||
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
|
||||
if (args.length == 0) {
|
||||
@@ -226,13 +271,36 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
player.openInventory(inv);
|
||||
}
|
||||
|
||||
// ---- limits and cooldowns (the same rules as the Fabric mod; see config.yml) ----
|
||||
|
||||
private final net.scopenet.core.Limits.Cooldowns cooldowns = new net.scopenet.core.Limits.Cooldowns();
|
||||
|
||||
private int tpaTimeoutSeconds() { return Math.max(5, plugin.getConfig().getInt("essentials.tpa-timeout-seconds", 60)); }
|
||||
|
||||
private int cooldownSeconds(String cmd) { return Math.max(0, plugin.getConfig().getInt("essentials.cooldowns." + cmd, 0)); }
|
||||
|
||||
/** True (and tells the player) if they must still wait before using this command. */
|
||||
private boolean coolingDown(Player player, String cmd) {
|
||||
if (cooldownSeconds(cmd) <= 0 || player.hasPermission("scopenet.cooldown.bypass")) return false;
|
||||
long wait = cooldowns.remainingMs(player.getUniqueId(), cmd, System.currentTimeMillis());
|
||||
if (wait <= 0) return false;
|
||||
player.sendMessage(ChatColor.RED + "Please wait " + net.scopenet.core.Limits.Cooldowns.waitText(wait) + " before using /" + cmd + " again.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void startCooldown(Player player, String cmd) {
|
||||
if (!player.hasPermission("scopenet.cooldown.bypass")) cooldowns.start(player.getUniqueId(), cmd, System.currentTimeMillis(), cooldownSeconds(cmd));
|
||||
}
|
||||
|
||||
private void handleSetHome(Player player, String[] args) {
|
||||
String homeName = args.length > 0 ? args[0].toLowerCase() : "home";
|
||||
int maxHomes = plugin.getConfig().getInt("essentials.max-homes", 5);
|
||||
int nameMax = Math.max(1, plugin.getConfig().getInt("essentials.home-name-max-length", 32));
|
||||
if (homeName.length() > nameMax) { player.sendMessage(ChatColor.RED + "Home names can be at most " + nameMax + " characters."); return; }
|
||||
int maxHomes = net.scopenet.core.Limits.tier(player::hasPermission, "scopenet.homes.", plugin.getConfig().getInt("essentials.max-homes", 5), 500);
|
||||
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
|
||||
|
||||
if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) {
|
||||
player.sendMessage(ChatColor.RED + "You have reached your limit of " + maxHomes + " homes.");
|
||||
player.sendMessage(ChatColor.RED + "You have reached your limit of " + net.scopenet.core.Limits.show(maxHomes) + " homes.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,18 +345,23 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
requestTpa(player, target);
|
||||
}
|
||||
|
||||
/** Ask {@code target} to accept a teleport of {@code player} to them. Also used for requests made from the map. */
|
||||
public void requestTpa(Player player, Player target) {
|
||||
pendingTpa.put(target.getUniqueId(), player.getUniqueId());
|
||||
tpaTimestamp.put(target.getUniqueId(), System.currentTimeMillis());
|
||||
|
||||
player.sendMessage(ChatColor.GREEN + "Teleport request sent to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + ".");
|
||||
target.sendMessage(ChatColor.YELLOW + player.getName() + ChatColor.GREEN + " has sent you a teleport request.");
|
||||
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in 60s).");
|
||||
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in " + tpaTimeoutSeconds() + "s).");
|
||||
}
|
||||
|
||||
private void handleTpAccept(Player player) {
|
||||
UUID requesterId = pendingTpa.get(player.getUniqueId());
|
||||
Long time = tpaTimestamp.get(player.getUniqueId());
|
||||
if (requesterId == null || time == null || System.currentTimeMillis() - time > 60_000) {
|
||||
if (requesterId == null || time == null || System.currentTimeMillis() - time > tpaTimeoutSeconds() * 1000L) {
|
||||
pendingTpa.remove(player.getUniqueId());
|
||||
tpaTimestamp.remove(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "You have no active teleport requests.");
|
||||
@@ -324,15 +397,18 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
}
|
||||
|
||||
private void handleRtp(Player player) {
|
||||
int radius = plugin.getConfig().getInt("essentials.rtp-radius", 2500);
|
||||
int radius = Math.max(10, plugin.getConfig().getInt("essentials.rtp-radius", 2500));
|
||||
int minRadius = Math.min(plugin.getConfig().getInt("essentials.rtp-min-radius", 0), radius - 1);
|
||||
int attempts = Math.max(1, plugin.getConfig().getInt("essentials.rtp-attempts", 15));
|
||||
World world = player.getWorld();
|
||||
Random rng = new Random();
|
||||
|
||||
player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination...");
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
for (int i = 0; i < 15; i++) {
|
||||
for (int i = 0; i < attempts; i++) {
|
||||
int x = rng.nextInt(radius * 2) - radius;
|
||||
int z = rng.nextInt(radius * 2) - radius;
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < minRadius) continue;
|
||||
int y = world.getHighestBlockYAt(x, z);
|
||||
Block block = world.getBlockAt(x, y - 1, z);
|
||||
if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) {
|
||||
@@ -362,6 +438,93 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||
player.sendMessage(ChatColor.GREEN + "Teleported to warp " + ChatColor.YELLOW + warpName + ChatColor.GREEN + ".");
|
||||
}
|
||||
|
||||
private String warpName(Player player, String[] args, String usage) {
|
||||
if (args.length != 1 || !args[0].matches("[A-Za-z0-9_-]{2,24}")) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: " + usage + " (2–24 letters, numbers, _ or -)");
|
||||
return null;
|
||||
}
|
||||
return args[0].toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void requestWarp(Player player, String[] args) {
|
||||
String name = warpName(player, args, "/reqwarp <name>");
|
||||
if (name == null) return;
|
||||
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "That warp already exists."); return; }
|
||||
if (warpRequestsConfig.contains(name) && !player.getUniqueId().toString().equals(warpRequestsConfig.getString(name + ".uuid"))) {
|
||||
player.sendMessage(ChatColor.RED + "That warp name has already been requested."); return;
|
||||
}
|
||||
warpRequestsConfig.set(name + ".location", player.getLocation());
|
||||
warpRequestsConfig.set(name + ".uuid", player.getUniqueId().toString());
|
||||
warpRequestsConfig.set(name + ".requester", player.getName());
|
||||
warpRequestsConfig.set(name + ".created_at", System.currentTimeMillis());
|
||||
saveWarpRequests();
|
||||
player.sendMessage(ChatColor.GREEN + "Warp request sent. A server admin can approve it with /warpapprove " + name + ".");
|
||||
Bukkit.getOnlinePlayers().stream().filter(p -> p.hasPermission("scopenet.command.warp.manage"))
|
||||
.forEach(p -> p.sendMessage(ChatColor.YELLOW + player.getName() + " requested warp '" + name + "'. Use /warprequests to review."));
|
||||
}
|
||||
|
||||
private boolean warpAdmin(Player player) {
|
||||
if (player.hasPermission("scopenet.command.warp.manage")) return true;
|
||||
player.sendMessage(ChatColor.RED + "Only server admins can manage warps.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void listWarpRequests(Player player) {
|
||||
if (!warpAdmin(player)) return;
|
||||
if (warpRequestsConfig.getKeys(false).isEmpty()) { player.sendMessage(ChatColor.YELLOW + "No pending warp requests."); return; }
|
||||
player.sendMessage(ChatColor.GOLD + "Pending warp requests:");
|
||||
for (String name : warpRequestsConfig.getKeys(false)) {
|
||||
Location at = warpRequestsConfig.getLocation(name + ".location");
|
||||
player.sendMessage(ChatColor.YELLOW + name + ChatColor.GRAY + " by " + warpRequestsConfig.getString(name + ".requester", "unknown")
|
||||
+ (at == null || at.getWorld() == null ? "" : " at " + at.getWorld().getName() + " " + at.getBlockX() + ", " + at.getBlockY() + ", " + at.getBlockZ()));
|
||||
}
|
||||
}
|
||||
|
||||
private void reviewWarp(Player player, String[] args, boolean approve) {
|
||||
if (!warpAdmin(player)) return;
|
||||
String name = warpName(player, args, approve ? "/warpapprove <name>" : "/warpdeny <name>");
|
||||
if (name == null) return;
|
||||
Location at = warpRequestsConfig.getLocation(name + ".location");
|
||||
if (at == null || at.getWorld() == null) { player.sendMessage(ChatColor.RED + "No pending request with a loaded world has that name."); return; }
|
||||
if (approve) {
|
||||
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "Warp already exists. Deny this request or choose another name."); return; }
|
||||
warps.put(name, at);
|
||||
saveWarps();
|
||||
}
|
||||
String uuid = warpRequestsConfig.getString(name + ".uuid", "");
|
||||
warpRequestsConfig.set(name, null);
|
||||
saveWarpRequests();
|
||||
player.sendMessage(ChatColor.GREEN + "Warp request " + (approve ? "approved" : "denied") + ": " + name);
|
||||
try {
|
||||
Player requester = Bukkit.getPlayer(UUID.fromString(uuid));
|
||||
if (requester != null) requester.sendMessage(ChatColor.YELLOW + "Your warp request '" + name + "' was " + (approve ? "approved" : "denied") + ".");
|
||||
} catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
|
||||
private void setWarp(Player player, String[] args) {
|
||||
if (!warpAdmin(player)) return;
|
||||
String name = warpName(player, args, "/setwarp <name>");
|
||||
if (name == null) return;
|
||||
int maxWarps = plugin.getConfig().getInt("essentials.max-warps", 0);
|
||||
if (maxWarps > 0 && !warps.containsKey(name) && warps.size() >= maxWarps) {
|
||||
player.sendMessage(ChatColor.RED + "The server already has " + maxWarps + " warps. Delete one first.");
|
||||
return;
|
||||
}
|
||||
warps.put(name, player.getLocation());
|
||||
saveWarps();
|
||||
player.sendMessage(ChatColor.GREEN + "Warp set: " + name);
|
||||
}
|
||||
|
||||
private void deleteWarp(Player player, String[] args) {
|
||||
if (!warpAdmin(player)) return;
|
||||
String name = warpName(player, args, "/delwarp <name>");
|
||||
if (name == null) return;
|
||||
if (warps.remove(name) == null) { player.sendMessage(ChatColor.RED + "Warp not found."); return; }
|
||||
warpsConfig.set(name, null);
|
||||
saveWarps();
|
||||
player.sendMessage(ChatColor.GREEN + "Warp deleted: " + name);
|
||||
}
|
||||
|
||||
private void openWarpPicker(Player player) {
|
||||
Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE);
|
||||
int slot = 11;
|
||||
|
||||
@@ -20,6 +20,8 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
@@ -34,6 +36,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
private final File guildHomesFile;
|
||||
private final YamlConfiguration guildHomesConfig;
|
||||
private final Map<String, Location> guildHomes = new ConcurrentHashMap<>();
|
||||
private final Set<UUID> autoClaim = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, String> lastAutoClaim = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String GUI_GUILD_TITLE = ChatColor.DARK_AQUA + "Guild Dashboard";
|
||||
|
||||
@@ -86,6 +90,23 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
handleUnclaim(player);
|
||||
return true;
|
||||
}
|
||||
if (cmd.equals("autoclaim")) {
|
||||
if (!player.hasPermission("scopenet.command.claim")) { player.sendMessage(ChatColor.RED + "You cannot claim land."); return true; }
|
||||
if (args.length != 1 || (!args[0].equalsIgnoreCase("on") && !args[0].equalsIgnoreCase("off"))) {
|
||||
player.sendMessage(ChatColor.YELLOW + "Usage: /autoclaim on|off"); return true;
|
||||
}
|
||||
if (args[0].equalsIgnoreCase("on")) {
|
||||
autoClaim.add(player.getUniqueId());
|
||||
lastAutoClaim.remove(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.GREEN + "Auto-claim is on. New chunks you walk into will be claimed for your guild.");
|
||||
claimOnMove(player, player.getLocation());
|
||||
} else {
|
||||
autoClaim.remove(player.getUniqueId());
|
||||
lastAutoClaim.remove(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.YELLOW + "Auto-claim is off.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cmd.equals("guild") || cmd.equals("g")) {
|
||||
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
|
||||
@@ -99,7 +120,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
|
||||
String sub = args[0].toLowerCase();
|
||||
String permission = sub.equals("c") ? "chat" : sub;
|
||||
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay").contains(permission)
|
||||
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline").contains(permission)
|
||||
&& !player.hasPermission("scopenet.command.guild." + permission)) {
|
||||
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
|
||||
return true;
|
||||
@@ -124,6 +145,9 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
case "members" -> handleMembers(player);
|
||||
case "bank" -> bank.bank(player, Arrays.copyOfRange(args, 1, args.length));
|
||||
case "pay" -> bank.pay(player, Arrays.copyOfRange(args, 1, args.length));
|
||||
case "invite" -> handleInvite(player, args);
|
||||
case "accept" -> handleInviteAnswer(player, args, true);
|
||||
case "decline", "deny" -> handleInviteAnswer(player, args, false);
|
||||
case "sell" -> economy.handleSell(player, Arrays.copyOfRange(args, 1, args.length), true);
|
||||
case "market" -> economy.handleMarket(player, Arrays.copyOfRange(args, 1, args.length), true);
|
||||
default -> sendGuildHelp(player);
|
||||
@@ -134,6 +158,46 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void handleInvite(Player player, String[] args) {
|
||||
if (args.length < 2) { player.sendMessage(ChatColor.RED + "Usage: /guild invite <player>"); return; }
|
||||
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
|
||||
body.addProperty("uuid", player.getUniqueId().toString());
|
||||
body.addProperty("target", args[1]);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
com.google.gson.JsonObject r = integration.client().post("guilds/invite/send", body);
|
||||
player.sendMessage(ChatColor.GREEN + "Invitation sent to " + ChatColor.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[1]) + ChatColor.GREEN + ".");
|
||||
} catch (Exception e) {
|
||||
player.sendMessage(ChatColor.RED + "Could not invite: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleInviteAnswer(Player player, String[] args, boolean accept) {
|
||||
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
|
||||
body.addProperty("uuid", player.getUniqueId().toString());
|
||||
body.addProperty("tag", args.length > 1 ? args[1] : "");
|
||||
body.addProperty("accept", accept);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
com.google.gson.JsonObject r = integration.client().post("guilds/invite/respond", body);
|
||||
if (accept) integration.client().requestClaimRefresh();
|
||||
player.sendMessage(accept
|
||||
? ChatColor.GREEN + "Welcome to " + ChatColor.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + ChatColor.GREEN + "!"
|
||||
: ChatColor.YELLOW + "Invitation declined.");
|
||||
} catch (Exception e) {
|
||||
player.sendMessage(ChatColor.RED + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Guild homes by guild id, for the map. */
|
||||
public Map<String, net.scopenet.core.Pos> guildHomesForMap() {
|
||||
Map<String, net.scopenet.core.Pos> out = new java.util.TreeMap<>();
|
||||
guildHomes.forEach((id, l) -> { if (l.getWorld() != null) out.put(id, new net.scopenet.core.Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch())); });
|
||||
return out;
|
||||
}
|
||||
|
||||
private void sendGuildHelp(Player player) {
|
||||
player.sendMessage(ChatColor.GOLD + "=== SCOPENET Guild Commands ===");
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild" + ChatColor.GRAY + " - Open interactive Guild Dashboard GUI");
|
||||
@@ -148,6 +212,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild sell [hand]" + ChatColor.GRAY + " - Sell items to the shop for the guild bank");
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild market sell <price> | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild");
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild pay <tag> <amount>" + ChatColor.GRAY + " - Pay another guild");
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild invite <player>" + ChatColor.GRAY + " - Invite a player (leaders and officers)");
|
||||
player.sendMessage(ChatColor.YELLOW + "/guild accept [tag] | decline [tag]" + ChatColor.GRAY + " - Answer a guild invitation");
|
||||
}
|
||||
|
||||
private void handleClaim(Player player) {
|
||||
@@ -166,6 +232,39 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (event.getTo() == null || !autoClaim.contains(event.getPlayer().getUniqueId())) return;
|
||||
if (event.getFrom().getWorld() == event.getTo().getWorld()
|
||||
&& event.getFrom().getChunk().getX() == event.getTo().getChunk().getX()
|
||||
&& event.getFrom().getChunk().getZ() == event.getTo().getChunk().getZ()) return;
|
||||
claimOnMove(event.getPlayer(), event.getTo());
|
||||
}
|
||||
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) {
|
||||
autoClaim.remove(event.getPlayer().getUniqueId());
|
||||
lastAutoClaim.remove(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
private void claimOnMove(Player player, Location location) {
|
||||
if (!integration.settings().landClaimingEnabled()) return;
|
||||
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
|
||||
String dim = ScopenetPlugin.dimension(location.getWorld());
|
||||
String key = dim + ':' + x + ':' + z;
|
||||
if (key.equals(lastAutoClaim.put(player.getUniqueId(), key))) return;
|
||||
if (integration.isClaimed(dim, x, z)) return;
|
||||
UUID uuid = player.getUniqueId();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
integration.client().claimChunk(dim, x, z, uuid);
|
||||
player.sendMessage(ChatColor.GREEN + "Auto-claimed chunk [" + x + ", " + z + "].");
|
||||
} catch (Exception e) {
|
||||
autoClaim.remove(uuid);
|
||||
player.sendMessage(ChatColor.RED + "Auto-claim stopped: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleUnclaim(Player player) {
|
||||
Chunk chunk = player.getLocation().getChunk();
|
||||
String dim = ScopenetPlugin.dimension(player.getWorld());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package net.scopenet.paper.commands;
|
||||
|
||||
import net.md_5.bungee.api.chat.ClickEvent;
|
||||
import net.md_5.bungee.api.chat.ComponentBuilder;
|
||||
import net.md_5.bungee.api.chat.HoverEvent;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Share a read-only snapshot of the held item through a clickable chat message. */
|
||||
public final class HandHandler implements CommandExecutor, Listener {
|
||||
private record Shared(ItemStack item, long expires) {}
|
||||
private final Map<String, Shared> shares = new ConcurrentHashMap<>();
|
||||
private static final String TITLE = ChatColor.DARK_GRAY + "Shared item";
|
||||
|
||||
public HandHandler(Plugin plugin) {
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> shares.entrySet().removeIf(e -> e.getValue().expires < System.currentTimeMillis()), 1200, 1200);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can use this command."); return true; }
|
||||
if (command.getName().equalsIgnoreCase("handview")) {
|
||||
if (args.length != 1) return false;
|
||||
Shared shared = shares.get(args[0]);
|
||||
if (shared == null || shared.expires < System.currentTimeMillis()) { player.sendMessage(ChatColor.RED + "That item share has expired."); return true; }
|
||||
Inventory view = Bukkit.createInventory(null, 9, TITLE);
|
||||
view.setItem(4, shared.item.clone());
|
||||
player.openInventory(view);
|
||||
return true;
|
||||
}
|
||||
ItemStack item = player.getInventory().getItemInMainHand();
|
||||
if (item.getType() == Material.AIR) { player.sendMessage(ChatColor.RED + "Hold an item first."); return true; }
|
||||
String token = UUID.randomUUID().toString().substring(0, 8);
|
||||
shares.put(token, new Shared(item.clone(), System.currentTimeMillis() + 300_000));
|
||||
String name = item.hasItemMeta() && item.getItemMeta().hasDisplayName()
|
||||
? item.getItemMeta().getDisplayName() : item.getType().name().toLowerCase().replace('_', ' ');
|
||||
TextComponent link = new TextComponent("[" + name + " ×" + item.getAmount() + "]");
|
||||
link.setColor(net.md_5.bungee.api.ChatColor.AQUA);
|
||||
link.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/handview " + token));
|
||||
link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("Click to inspect " + name + " (5 minutes)").create()));
|
||||
TextComponent line = new TextComponent(player.getDisplayName() + ChatColor.WHITE + " is holding ");
|
||||
line.addExtra(link);
|
||||
for (Player target : Bukkit.getOnlinePlayers()) {
|
||||
target.spigot().sendMessage(line);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler public void onInventoryClick(InventoryClickEvent event) {
|
||||
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
|
||||
}
|
||||
@EventHandler public void onInventoryDrag(InventoryDragEvent event) {
|
||||
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.scopenet.paper.commands;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Player-owned chat nicknames, keyed by UUID and restored on every join. */
|
||||
public final class NicknameHandler implements CommandExecutor, Listener {
|
||||
private final Plugin plugin;
|
||||
private final File file;
|
||||
private final YamlConfiguration config;
|
||||
|
||||
public NicknameHandler(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.file = new File(plugin.getDataFolder(), "nicknames.yml");
|
||||
this.config = YamlConfiguration.loadConfiguration(file);
|
||||
}
|
||||
|
||||
@EventHandler public void join(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
String saved = config.getString(player.getUniqueId().toString());
|
||||
if (saved != null && !saved.isBlank()) player.setDisplayName(ChatColor.translateAlternateColorCodes('&', saved));
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can set a nickname."); return true; }
|
||||
if (args.length != 1) { player.sendMessage(ChatColor.YELLOW + "Usage: /nick <name|off>"); return true; }
|
||||
String value = args[0];
|
||||
if (value.equalsIgnoreCase("off")) {
|
||||
config.set(player.getUniqueId().toString(), null);
|
||||
player.setDisplayName(player.getName());
|
||||
save();
|
||||
player.sendMessage(ChatColor.GREEN + "Nickname removed.");
|
||||
return true;
|
||||
}
|
||||
String plain = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', value));
|
||||
if (plain.length() < 2 || plain.length() > 24 || !plain.matches("[A-Za-z0-9_ -]+")) {
|
||||
player.sendMessage(ChatColor.RED + "Nicknames must be 2–24 letters, numbers, spaces, _ or -.");
|
||||
return true;
|
||||
}
|
||||
if (!player.hasPermission("scopenet.chat.color")) value = plain;
|
||||
config.set(player.getUniqueId().toString(), value);
|
||||
player.setDisplayName(ChatColor.translateAlternateColorCodes('&', value));
|
||||
save();
|
||||
player.sendMessage(ChatColor.GREEN + "Nickname set to " + player.getDisplayName() + ChatColor.GREEN + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try { config.save(file); }
|
||||
catch (IOException e) { plugin.getLogger().warning("Failed to save nicknames: " + e.getMessage()); }
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ public final class LuckPermsModule implements IntegrationModule {
|
||||
JsonObject config = answer.has("config") && answer.get("config").isJsonObject() ? answer.getAsJsonObject("config") : new JsonObject();
|
||||
if (config.has("mode")) lastMode = config.get("mode").getAsString();
|
||||
if (context.getBoolean("luckperms", "apply-panel-groups", false) && config.has("assign")) apply(config.getAsJsonArray("assign"));
|
||||
if (config.has("level_assign")) apply(config.getAsJsonArray("level_assign"));
|
||||
} catch (Exception e) {
|
||||
context.log().fine("LuckPerms report failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class ScopenetApiImpl implements ScopenetApi, AutoCloseable {
|
||||
guild == null ? null : new GuildSummary(str(guild, "id"), str(guild, "name"), str(guild, "tag"), str(guild, "role"), (int) num(guild, "claims")),
|
||||
dbl(j, "balance"), num(j, "playtime_secs"), num(j, "server_playtime_secs"), (int) num(j, "kills"), (int) num(j, "deaths"),
|
||||
(int) num(j, "friends"), (int) num(j, "achievements"), quests(j, "daily"), quests(j, "weekly"),
|
||||
str(j, "joined"), str(rank, "group"), str(rank, "prefix"));
|
||||
str(j, "joined"), str(rank, "group"), str(rank, "display"), str(rank, "prefix"), str(rank, "suffix"));
|
||||
}
|
||||
|
||||
static ScopenetGuild guild(JsonObject j) {
|
||||
|
||||
@@ -46,16 +46,38 @@ social:
|
||||
essentials:
|
||||
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
|
||||
enabled: true
|
||||
# Maximum homes per player
|
||||
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
|
||||
# scopenet.homes.25 gives 25 (the highest one a player has wins), scopenet.homes.unlimited removes the limit.
|
||||
# Operators and anyone with "*" get the top tier.
|
||||
max-homes: 5
|
||||
# RTP search radius
|
||||
# Longest allowed home name.
|
||||
home-name-max-length: 32
|
||||
# Most warps the server may have (0 = no limit).
|
||||
max-warps: 0
|
||||
# How long a /tpa request stays open.
|
||||
tpa-timeout-seconds: 60
|
||||
# /rtp picks a random spot between rtp-min-radius and rtp-radius blocks from the centre (0,0).
|
||||
rtp-radius: 2500
|
||||
rtp-min-radius: 0
|
||||
# How many random spots /rtp tries before giving up.
|
||||
rtp-attempts: 15
|
||||
# Seconds a player must wait between uses (0 = no cooldown). The wait starts when the command actually
|
||||
# teleports, so a typo costs nothing. Players with scopenet.cooldown.bypass skip all of these.
|
||||
cooldowns:
|
||||
spawn: 0
|
||||
home: 0
|
||||
back: 0
|
||||
tpa: 0
|
||||
rtp: 0
|
||||
warp: 0
|
||||
|
||||
livemap:
|
||||
# Send world chunks and live player positions to the panel's central Live Map.
|
||||
# Players open it from the launcher; you don't run Vantage on this server.
|
||||
# It also needs "Live Map" switched on for this server in the panel.
|
||||
map:
|
||||
# The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed,
|
||||
# with player positions, guild claims and pins, to the panel. Players see it in the launcher; admins in the panel.
|
||||
# It also needs the map switched on for this server in the panel (it is by default).
|
||||
enabled: true
|
||||
# Show every player's homes as pins. Off by default: homes are private.
|
||||
show-homes: false
|
||||
|
||||
economy:
|
||||
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
|
||||
@@ -94,6 +116,23 @@ integrations:
|
||||
enabled: true
|
||||
report-interval-seconds: 30
|
||||
|
||||
# ==============================================================================
|
||||
# Rewards
|
||||
# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim
|
||||
# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands
|
||||
# to this server when the player is online. Permissions and groups are applied with console commands, so any
|
||||
# permissions plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}.
|
||||
# ==============================================================================
|
||||
rewards:
|
||||
enabled: true
|
||||
# Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the panel
|
||||
# could otherwise run commands on this server. Only switch it on if you trust everyone with panel admin access.
|
||||
allow-commands: false
|
||||
permission-command: "lp user {player} permission set {node} true"
|
||||
permission-deny-command: "lp user {player} permission set {node} false"
|
||||
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
|
||||
group-command: "lp user {player} parent add {group}"
|
||||
|
||||
# ==============================================================================
|
||||
# Client mod link
|
||||
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
|
||||
|
||||
@@ -50,10 +50,37 @@ commands:
|
||||
permission: scopenet.command.warp
|
||||
description: Teleport to a server warp or open the warps GUI
|
||||
aliases: [warps]
|
||||
reqwarp:
|
||||
permission: scopenet.command.reqwarp
|
||||
description: Request a new public warp at your location
|
||||
warprequests:
|
||||
permission: scopenet.command.warp.manage
|
||||
description: List pending warp requests
|
||||
warpapprove:
|
||||
permission: scopenet.command.warp.manage
|
||||
description: Approve a pending warp request
|
||||
warpdeny:
|
||||
permission: scopenet.command.warp.manage
|
||||
description: Deny a pending warp request
|
||||
setwarp:
|
||||
permission: scopenet.command.warp.manage
|
||||
description: Set a public warp at your location
|
||||
delwarp:
|
||||
permission: scopenet.command.warp.manage
|
||||
description: Delete a public warp
|
||||
playtime:
|
||||
permission: scopenet.command.playtime
|
||||
description: Check total and session playtime
|
||||
aliases: [ontime]
|
||||
hand:
|
||||
permission: scopenet.command.hand
|
||||
description: Share your held item in chat
|
||||
handview:
|
||||
permission: scopenet.command.hand
|
||||
description: Inspect a shared item
|
||||
nick:
|
||||
permission: scopenet.command.nick
|
||||
description: Set or remove your chat nickname
|
||||
balance:
|
||||
permission: scopenet.command.balance
|
||||
description: Check your server economy balance
|
||||
@@ -94,6 +121,9 @@ commands:
|
||||
unclaim:
|
||||
permission: scopenet.command.unclaim
|
||||
description: Unclaim the current chunk
|
||||
autoclaim:
|
||||
permission: scopenet.command.claim
|
||||
description: Claim chunks as you walk into them
|
||||
|
||||
|
||||
permissions:
|
||||
@@ -137,9 +167,21 @@ permissions:
|
||||
scopenet.command.warp:
|
||||
description: Use /warp
|
||||
default: true
|
||||
scopenet.command.reqwarp:
|
||||
description: Request a public warp
|
||||
default: true
|
||||
scopenet.command.warp.manage:
|
||||
description: Review and manage public warps
|
||||
default: op
|
||||
scopenet.command.playtime:
|
||||
description: Use /playtime
|
||||
default: true
|
||||
scopenet.command.hand:
|
||||
description: Share and inspect held items in chat
|
||||
default: true
|
||||
scopenet.command.nick:
|
||||
description: Set or remove your chat nickname
|
||||
default: true
|
||||
scopenet.command.balance:
|
||||
description: Use /balance
|
||||
default: true
|
||||
@@ -176,6 +218,9 @@ permissions:
|
||||
scopenet.command.unclaim:
|
||||
description: Use /unclaim
|
||||
default: true
|
||||
scopenet.chat.color:
|
||||
description: Use Minecraft color codes in chat
|
||||
default: op
|
||||
scopenet.command.scopenet.help:
|
||||
description: Use /scopenet help
|
||||
default: true
|
||||
@@ -242,6 +287,18 @@ permissions:
|
||||
scopenet.command.guild.pay:
|
||||
description: Pay another guild (/guild pay)
|
||||
default: true
|
||||
scopenet.command.guild.invite:
|
||||
description: Invite a player to your guild (/guild invite); the panel also requires leader or officer
|
||||
default: true
|
||||
scopenet.command.guild.accept:
|
||||
description: Accept a guild invitation (/guild accept)
|
||||
default: true
|
||||
scopenet.command.guild.decline:
|
||||
description: Decline a guild invitation (/guild decline)
|
||||
default: true
|
||||
scopenet.command.market.point:
|
||||
description: Create and remove shop points, blocks that open the market or shop (/market point)
|
||||
default: op
|
||||
scopenet.command.sell.hand:
|
||||
description: Use /sell hand
|
||||
default: true
|
||||
@@ -278,6 +335,9 @@ permissions:
|
||||
scopenet.command.guild.market.sell: true
|
||||
scopenet.command.guild.market.buy: true
|
||||
scopenet.command.guild.pay: true
|
||||
scopenet.command.guild.invite: true
|
||||
scopenet.command.guild.accept: true
|
||||
scopenet.command.guild.decline: true
|
||||
scopenet.command.*:
|
||||
description: Every SCOPENET command and subcommand, including admin ones
|
||||
default: false
|
||||
@@ -328,8 +388,12 @@ permissions:
|
||||
scopenet.command.guild.market.sell: true
|
||||
scopenet.command.guild.market.buy: true
|
||||
scopenet.command.guild.pay: true
|
||||
scopenet.command.guild.invite: true
|
||||
scopenet.command.guild.accept: true
|
||||
scopenet.command.guild.decline: true
|
||||
scopenet.command.sell.hand: true
|
||||
scopenet.command.market.sell: true
|
||||
scopenet.command.market.point: true
|
||||
scopenet.command.market.buy: true
|
||||
scopenet.command.economy.*:
|
||||
description: All economy commands
|
||||
|
||||
Generated
+3
-96
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "scopenet-launcher",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "scopenet-launcher",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
@@ -15,29 +15,19 @@
|
||||
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.6.0",
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
"@tauri-apps/plugin-opener": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tauri-apps/cli": "^2.12.0",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^8.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
@@ -700,25 +690,6 @@
|
||||
"@tauri-apps/api": "^2.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thoughts-on-things/vantage-mc": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@thoughts-on-things/vantage-mc/-/vantage-mc-0.15.1.tgz",
|
||||
"integrity": "sha512-muhg9950CgOa8keoCUV+utDHUKhkckwWGVn5O3HFMrgr7OrPo3VpEsBYVSUVL/RjmQDiqEaC19FPLGMTH97jpg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"three": ">=0.160"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||
@@ -726,13 +697,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -740,43 +704,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
@@ -906,13 +833,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1216,13 +1136,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -1448,12 +1361,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scopenet-launcher",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,16 +18,13 @@
|
||||
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.6.0",
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
"@tauri-apps/plugin-opener": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tauri-apps/cli": "^2.12.0",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
|
||||
@@ -150,6 +150,16 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f64) -> Res<()> {
|
||||
if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() {
|
||||
return Err("FOV must be between 30 and 110 degrees".into());
|
||||
}
|
||||
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default()
|
||||
.insert("fov".into(), format!("{fov:.3}"));
|
||||
state.save_settings().map_err(aerr)
|
||||
}
|
||||
|
||||
// ---- accounts ----
|
||||
|
||||
#[tauri::command]
|
||||
@@ -625,111 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
if !resp.status().is_success() {
|
||||
return Err("unable to load servers".into());
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
let urls = data["servers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
|
||||
.collect();
|
||||
*state.map_urls.lock().unwrap() = urls;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MapResource {
|
||||
status: u16,
|
||||
data: Option<String>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
/// Native transport for Vantage assets. Browser WebViews cannot fetch a
|
||||
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
|
||||
/// Only paths under a map URL supplied by the panel can be fetched.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||
let base = reqwest::Url::parse(&configured).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
let root = base.join(".").map_err(err)?;
|
||||
if !matches!(target.scheme(), "http" | "https")
|
||||
|| target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside the configured Vantage world".into());
|
||||
}
|
||||
fetch_map_asset(target, etag, None).await
|
||||
}
|
||||
|
||||
/// What the launcher needs to offer a Live Map button for one game server.
|
||||
#[tauri::command]
|
||||
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
/// Native transport for the panel-hosted live map. The session token is
|
||||
/// attached here, so it never reaches page scripts, and only URLs under
|
||||
/// this panel's `/api/livemap/<server>/` can be fetched.
|
||||
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
|
||||
/// because the map loads them straight from the panel as images).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_livemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let token = accounts::panel_token(&state).ok_or("sign in to view the live map")?;
|
||||
let base = reqwest::Url::parse(&format!("{}/api/livemap/{server_id}/", panel.trim_end_matches('/'))).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
if target.origin() != base.origin()
|
||||
|| !target.path().starts_with(base.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside this server's live map".into());
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
fetch_map_asset(target, etag, Some(token)).await
|
||||
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
|
||||
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
|
||||
use base64::Engine;
|
||||
if target.as_str().len() > 2048 {
|
||||
return Err("map asset URL is too long".into());
|
||||
/// Players now, plus the claims and pins the game server last sent.
|
||||
#[tauri::command]
|
||||
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(err)?;
|
||||
let mut request = client.get(target);
|
||||
if let Some(token) = bearer {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
}
|
||||
let response = request.send().await.map_err(err)?;
|
||||
let status = response.status().as_u16();
|
||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if status == 304 {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(err)?;
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -746,6 +682,17 @@ pub async fn get_my_level(state: State<'_, AppState>) -> Res<UserLevelInfo> {
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
/// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:<id>`.
|
||||
#[tauri::command]
|
||||
pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/reward-bundles").await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_my_quests(state: State<'_, AppState>) -> Res<Vec<UserQuest>> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?;
|
||||
@@ -807,6 +754,71 @@ pub async fn get_my_guild(state: State<'_, AppState>, instance_id: Option<String
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
body["error"].as_str().unwrap_or(fallback).to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?;
|
||||
let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); }
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?;
|
||||
let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); }
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")).await?.json(&serde_json::json!({"role":role})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_guild(state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")).await?
|
||||
.json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url}))
|
||||
.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild(
|
||||
state: State<'_, AppState>,
|
||||
@@ -1190,3 +1202,51 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
|
||||
let body: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
Ok(body["liked"].as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map actions and guild invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn ok_or_panel_error(resp: reqwest::Response, fallback: &str) -> Res<serde_json::Value> {
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp.json().await.unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
Err(body["error"].as_str().or(body["message"].as_str()).unwrap_or(fallback).to_string())
|
||||
}
|
||||
|
||||
/// From the map: ask a player to teleport to, or send them a message in game.
|
||||
#[tauri::command]
|
||||
pub async fn map_action(state: State<'_, AppState>, server_id: i64, action: String, target_uuid: String, text: Option<String>) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/servers/{server_id}/map/actions")).await?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({ "action": action, "target_uuid": target_uuid, "text": text.unwrap_or_default() }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
ok_or_panel_error(resp, "unable to do that right now").await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Invite a player to a guild you lead or officiate.
|
||||
#[tauri::command]
|
||||
pub async fn guild_send_invite(state: State<'_, AppState>, guild_id: String, target_uuid: String) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/invites")).await?;
|
||||
let resp = req.json(&serde_json::json!({ "uuid": target_uuid })).send().await.map_err(err)?;
|
||||
ok_or_panel_error(resp, "unable to send the invitation").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn my_guild_invites(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/guilds/invites").await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
ok_or_panel_error(resp, "unable to load invitations").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn respond_guild_invite(state: State<'_, AppState>, invite_id: i64, accept: bool) -> Res<serde_json::Value> {
|
||||
let path = format!("/guilds/invites/{invite_id}/{}", if accept { "accept" } else { "decline" });
|
||||
let req = account_api(&state, reqwest::Method::POST, &path).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
ok_or_panel_error(resp, "unable to answer the invitation").await
|
||||
}
|
||||
@@ -129,6 +129,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
} else {
|
||||
current_options
|
||||
};
|
||||
if let Some(fov) = settings.instance_game_options.get(&instance_id).and_then(|o| o.get("fov")) {
|
||||
saved_options.insert("fov".into(), fov.clone());
|
||||
}
|
||||
// Do not rewrite pack files or options while another copy is using this directory.
|
||||
if !already_running {
|
||||
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
|
||||
|
||||
@@ -52,12 +52,17 @@ pub fn run() {
|
||||
commands::set_panel_url,
|
||||
commands::save_settings,
|
||||
commands::save_instance_options,
|
||||
commands::set_instance_fov,
|
||||
commands::login_panel,
|
||||
commands::discord_sign_in_start,
|
||||
commands::discord_sign_in_poll,
|
||||
commands::request_password_reset,
|
||||
commands::discord_connection,
|
||||
commands::discord_info,
|
||||
commands::map_action,
|
||||
commands::guild_send_invite,
|
||||
commands::my_guild_invites,
|
||||
commands::respond_guild_invite,
|
||||
commands::discord_link_start,
|
||||
commands::discord_unlink,
|
||||
commands::register_panel,
|
||||
@@ -92,15 +97,23 @@ pub fn run() {
|
||||
commands::get_player_stats,
|
||||
commands::get_leaderboard,
|
||||
commands::get_public_servers,
|
||||
commands::fetch_vantage_resource,
|
||||
commands::fetch_livemap_resource,
|
||||
commands::get_livemap_info,
|
||||
commands::get_map_info,
|
||||
commands::get_map_overlay,
|
||||
commands::get_my_level,
|
||||
commands::get_my_quests,
|
||||
commands::get_reward_summaries,
|
||||
commands::claim_quest,
|
||||
commands::get_my_achievements,
|
||||
commands::get_guilds,
|
||||
commands::get_my_guild,
|
||||
commands::request_guild_join,
|
||||
commands::get_guild_join_requests,
|
||||
commands::respond_guild_join_request,
|
||||
commands::get_guild_roles,
|
||||
commands::create_guild_role,
|
||||
commands::assign_guild_role,
|
||||
commands::delete_guild_role,
|
||||
commands::update_guild,
|
||||
commands::create_guild,
|
||||
commands::get_guild_claims,
|
||||
commands::claim_guild_chunk,
|
||||
|
||||
@@ -4,15 +4,20 @@ use crate::settings::Settings;
|
||||
use scopenet_core::Layout;
|
||||
use scopenet_shared::LauncherManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||
pub mod build {
|
||||
/// Default panel URL for this build.
|
||||
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
|
||||
pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") {
|
||||
Some(u) if !u.is_empty() => Some(u),
|
||||
_ => Some("https://scopenetmcpanel.scopedd.lol"),
|
||||
};
|
||||
/// `owner/repo` used to check GitHub releases for updates.
|
||||
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
|
||||
pub const REPO: Option<&str> = match option_env!("SCOPENET_REPO") {
|
||||
Some(r) if !r.is_empty() => Some(r),
|
||||
_ => Some("scopeddlol/SCOPENET-MC"),
|
||||
};
|
||||
/// When set to "1", players can't change the panel URL.
|
||||
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
|
||||
|
||||
@@ -35,8 +40,6 @@ pub struct AppState {
|
||||
pub accounts: RwLock<AccountsFile>,
|
||||
pub secrets: Secrets,
|
||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||
/// Vantage URLs received from the panel, keyed by linked game server.
|
||||
pub map_urls: Mutex<HashMap<i64, String>>,
|
||||
/// Install/launch pipeline in progress (abortable).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Vec<RunningGame>>,
|
||||
@@ -55,7 +58,6 @@ impl AppState {
|
||||
settings: RwLock::new(settings),
|
||||
accounts: RwLock::new(accounts),
|
||||
manifest: RwLock::new(manifest),
|
||||
map_urls: Mutex::new(HashMap::new()),
|
||||
task: Mutex::new(None),
|
||||
game: Mutex::new(Vec::new()),
|
||||
data_dir,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SCOPENET Launcher",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"identifier": "net.scopenet.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; frame-src https: http:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+14
-1
@@ -40,10 +40,17 @@ html, body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#app { height: 100%; }
|
||||
|
||||
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
|
||||
*, *::before, *::after { min-width: 0; }
|
||||
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
|
||||
|
||||
|
||||
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
|
||||
p { margin: 0; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
@@ -85,8 +92,11 @@ button {
|
||||
font-weight: 520;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
|
||||
white-space: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; }
|
||||
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
|
||||
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
@@ -154,3 +164,6 @@ label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
|
||||
code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; }
|
||||
pre { white-space: pre-wrap; }
|
||||
@@ -1,524 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
||||
MapPin, Globe, LoaderCircle, RefreshCw, Server
|
||||
} from '@lucide/svelte';
|
||||
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { abs, toast } from '../lib/store.svelte';
|
||||
import { activeAccount, toast } from '../lib/store.svelte';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
import VantageMap from './VantageMap.svelte';
|
||||
import ClaimTerrain from './ClaimTerrain.svelte';
|
||||
|
||||
let {
|
||||
instanceId,
|
||||
myGuildId = null,
|
||||
myGuildTag = '',
|
||||
onclaim,
|
||||
onunclaim
|
||||
}: {
|
||||
instanceId: string;
|
||||
myGuildId?: string | null;
|
||||
myGuildTag?: string;
|
||||
onclaim?: (claim: GuildClaim) => void;
|
||||
onunclaim?: (claimId: number) => void;
|
||||
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
||||
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
||||
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
||||
} = $props();
|
||||
|
||||
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]);
|
||||
type GameServer = { id: number; name: string; instance_id: string; map?: boolean };
|
||||
type Player = { x: number; z: number; dimension: string };
|
||||
let servers = $state<GameServer[]>([]);
|
||||
let serverId = $state<number | null>(null);
|
||||
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
|
||||
let centerX = $state(0);
|
||||
let centerZ = $state(0);
|
||||
let radius = $state(5); // 11x11 grid
|
||||
let centerX = $state(0), centerZ = $state(0);
|
||||
let player = $state<Player | null>(null);
|
||||
let claims = $state<GuildClaim[]>([]);
|
||||
let loading = $state(false);
|
||||
let claiming = $state(false);
|
||||
let loading = $state(false), painting = $state(false);
|
||||
let claimLoad = 0;
|
||||
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
|
||||
let selectedChunk = $state<{ x: number; z: number } | null>(null);
|
||||
let mapDimensions = $state<string[]>([]);
|
||||
let mapFailed = $state(false);
|
||||
// The panel's central live map (if switched on for this server) wins over an external Vantage URL.
|
||||
type LiveInfo = { ready: boolean; base: string; dimensions: { id: string; available: boolean }[] };
|
||||
let liveInfo = $state<LiveInfo | null>(null);
|
||||
let centeredServer = 0;
|
||||
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
|
||||
const selectedServer = $derived(servers.find((s) => s.id === serverId));
|
||||
$effect(() => {
|
||||
const id = selectedServer?.live_map ? selectedServer.id : null;
|
||||
liveInfo = null;
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
invoke<LiveInfo>('get_livemap_info', { serverId: id }).then((i) => { if (alive) liveInfo = i; }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
});
|
||||
const central = $derived(!!liveInfo?.ready);
|
||||
const dimSlug = $derived(dimension.replace('minecraft:', ''));
|
||||
const mapUrl = $derived(central && liveInfo ? abs(`${liveInfo.base}/${dimSlug}/v1/worlds/default/manifest.json`) ?? '' : selectedServer?.map_url || '');
|
||||
const dimDisabled = (id: string) => central
|
||||
? !liveInfo?.dimensions.find((d) => d.id === id)?.available
|
||||
: !!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(id);
|
||||
$effect(() => { mapUrl; mapFailed = false; if (!central) mapDimensions = []; });
|
||||
|
||||
const selectedServer = $derived(instanceServers.find((s) => s.id === serverId));
|
||||
const dimensions = [
|
||||
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
|
||||
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
|
||||
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
|
||||
{ id: 'minecraft:the_nether', label: 'Nether', icon: Compass },
|
||||
{ id: 'minecraft:the_end', label: 'End', icon: Compass }
|
||||
] as const;
|
||||
const myClaimsCount = $derived(claims.filter((c) => c.guild_id === myGuildId).length);
|
||||
|
||||
async function refreshPosition(id: number) {
|
||||
try {
|
||||
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
|
||||
if (serverId !== id) return;
|
||||
const mine = activeAccount()?.uuid?.toLowerCase();
|
||||
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
|
||||
if (player && centeredServer !== id) {
|
||||
centeredServer = id;
|
||||
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
||||
centerX = Math.floor(player.x / 16);
|
||||
centerZ = Math.floor(player.z / 16);
|
||||
}
|
||||
} catch { player = null; }
|
||||
}
|
||||
|
||||
async function loadClaims() {
|
||||
const request = ++claimLoad;
|
||||
if (!instanceId || !serverId) { claims = []; return; }
|
||||
if (!serverId || !instanceId) { claims = []; return; }
|
||||
loading = true;
|
||||
claims = [];
|
||||
try {
|
||||
const next = await invoke<GuildClaim[]>('get_guild_claims', {
|
||||
instanceId,
|
||||
serverId,
|
||||
centerX,
|
||||
centerZ,
|
||||
dimension
|
||||
});
|
||||
const next = await invoke<GuildClaim[]>('get_guild_claims', { instanceId, serverId, dimension, centerX, centerZ });
|
||||
if (request === claimLoad) claims = next;
|
||||
} catch (e: any) {
|
||||
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error');
|
||||
} finally {
|
||||
if (request === claimLoad) loading = false;
|
||||
}
|
||||
if (request === claimLoad) toast(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
|
||||
} finally { if (request === claimLoad) loading = false; }
|
||||
}
|
||||
|
||||
function getClaimAt(x: number, z: number): GuildClaim | undefined {
|
||||
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
|
||||
}
|
||||
|
||||
const selectedClaim = $derived(
|
||||
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
|
||||
);
|
||||
|
||||
const myClaimsCount = $derived(
|
||||
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
|
||||
);
|
||||
|
||||
async function handleClaim() {
|
||||
if (!selectedChunk || !myGuildId || !serverId) return;
|
||||
claiming = true;
|
||||
async function paintChunks(chunks: { x: number; z: number }[], mode: 'claim' | 'unclaim') {
|
||||
if (!serverId || !myGuildId || painting) return;
|
||||
const existing = new Map(claims.map((c) => [`${c.chunk_x}:${c.chunk_z}`, c]));
|
||||
const wanted = chunks.filter(({ x, z }) => {
|
||||
const current = existing.get(`${x}:${z}`);
|
||||
return mode === 'claim' ? !current : current?.guild_id === myGuildId;
|
||||
});
|
||||
if (!wanted.length) return;
|
||||
painting = true;
|
||||
let done = 0;
|
||||
try {
|
||||
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
|
||||
guildId: myGuildId,
|
||||
instanceId,
|
||||
serverId,
|
||||
dimension,
|
||||
chunkX: selectedChunk.x,
|
||||
chunkZ: selectedChunk.z
|
||||
});
|
||||
claims = [...claims, claim];
|
||||
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
|
||||
onclaim?.(claim);
|
||||
} catch (e: any) {
|
||||
toast(e?.message ?? 'Failed to claim chunk', 'error');
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
for (const chunk of wanted) {
|
||||
try {
|
||||
if (mode === 'claim') {
|
||||
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
|
||||
guildId: myGuildId, instanceId, serverId, dimension, chunkX: chunk.x, chunkZ: chunk.z
|
||||
});
|
||||
claims = [...claims, claim];
|
||||
onclaim?.(claim);
|
||||
} else {
|
||||
const claim = existing.get(`${chunk.x}:${chunk.z}`)!;
|
||||
await invoke('unclaim_guild_chunk', { claimId: claim.id });
|
||||
claims = claims.filter((c) => c.id !== claim.id);
|
||||
onunclaim?.(claim.id);
|
||||
}
|
||||
done++;
|
||||
} catch (e: any) {
|
||||
toast(`${done} of ${wanted.length} chunks updated. ${typeof e === 'string' ? e : e?.message ?? 'Claim failed'}`, 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (done === wanted.length) toast(`${done} ${done === 1 ? 'chunk' : 'chunks'} ${mode === 'claim' ? 'claimed' : 'unclaimed'}`, 'ok');
|
||||
} finally { painting = false; await loadClaims(); }
|
||||
}
|
||||
|
||||
async function handleUnclaim() {
|
||||
if (!selectedClaim) return;
|
||||
claiming = true;
|
||||
try {
|
||||
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
|
||||
claims = claims.filter((c) => c.id !== selectedClaim.id);
|
||||
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
|
||||
onunclaim?.(selectedClaim.id);
|
||||
selectedChunk = null;
|
||||
} catch (e: any) {
|
||||
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
function centerOnPlayer() {
|
||||
if (!player) return;
|
||||
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
||||
centerX = Math.floor(player.x / 16);
|
||||
centerZ = Math.floor(player.z / 16);
|
||||
}
|
||||
|
||||
function pan(dx: number, dz: number) {
|
||||
centerX += dx;
|
||||
centerZ += dz;
|
||||
}
|
||||
|
||||
function resetCenter() {
|
||||
centerX = 0;
|
||||
centerZ = 0;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (instanceId && dimension && serverId) {
|
||||
loadClaims();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const result = await invoke<{ servers: typeof servers }>('get_public_servers');
|
||||
servers = result.servers;
|
||||
onMount(() => {
|
||||
invoke<{ servers: GameServer[] }>('get_public_servers').then((r) => { servers = r.servers; })
|
||||
.catch((e: any) => { toast(typeof e === 'string' ? e : 'Could not load servers', 'error'); });
|
||||
const timer = setInterval(() => { if (serverId) refreshPosition(serverId); }, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
$effect(() => {
|
||||
const available = servers.filter(s => s.instance_id === instanceId);
|
||||
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null;
|
||||
const available = instanceServers;
|
||||
if (!available.some((s) => s.id === serverId)) serverId = available[0]?.id ?? null;
|
||||
});
|
||||
$effect(() => { if (serverId) { player = null; centeredServer = 0; refreshPosition(serverId); } });
|
||||
$effect(() => { if (serverId && instanceId && dimension) loadClaims(); });
|
||||
</script>
|
||||
|
||||
<div class="chunk-map-container glass">
|
||||
<div class="claim-map glass">
|
||||
<div class="toolbar">
|
||||
<div class="tb-group">
|
||||
<div class="group">
|
||||
{#if instanceServers.length > 1}
|
||||
<label class="server-pick" title="Game server"><Server size={14} />
|
||||
<select bind:value={serverId} aria-label="Game server">
|
||||
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if instanceServers[0]}
|
||||
<span class="chip server-chip"><Server size={13} /> {instanceServers[0].name}</span>
|
||||
{/if}
|
||||
<div class="dim-selector" role="tablist" aria-label="Dimension">
|
||||
<label class="server"><ServerIcon size={14} /><select bind:value={serverId} aria-label="Game server">
|
||||
{#each instanceServers as s}<option value={s.id}>{s.name}</option>{/each}
|
||||
</select></label>
|
||||
{:else if selectedServer}<span class="chip"><ServerIcon size={14} /> {selectedServer.name}</span>{/if}
|
||||
<div class="dimensions" role="tablist" aria-label="Dimension">
|
||||
{#each dimensions as dim}
|
||||
{@const DimIcon = dim.icon}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={dimension === dim.id}
|
||||
class="dim-btn"
|
||||
class:active={dimension === dim.id}
|
||||
disabled={dimDisabled(dim.id)}
|
||||
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
|
||||
>
|
||||
<DimIcon size={14} />
|
||||
<span>{dim.label}</span>
|
||||
</button>
|
||||
{@const Icon = dim.icon}
|
||||
<button role="tab" aria-selected={dimension === dim.id} class:active={dimension === dim.id}
|
||||
onclick={() => (dimension = dim.id)}><Icon size={14} /> {dim.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="tb-group">
|
||||
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span>
|
||||
<div class="pad">
|
||||
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan west" aria-label="Pan west"><ArrowLeft size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan north" aria-label="Pan north"><ArrowUp size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={resetCenter} title="Back to the world origin" aria-label="Centre on origin"><MapPin size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan south" aria-label="Pan south"><ArrowDown size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan east" aria-label="Pan east"><ArrowRight size={14} /></button>
|
||||
</div>
|
||||
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims">
|
||||
<RefreshCw size={14} class={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
<div class="group">
|
||||
{#if player}<span class="position">You: {Math.floor(player.x)}, {Math.floor(player.z)}</span>{:else}<span class="position">Player position unavailable</span>{/if}
|
||||
<button class="ghost sm" onclick={centerOnPlayer} disabled={!player} title="Center on your current position"><LocateFixed size={15} /> Find me</button>
|
||||
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !serverId}
|
||||
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Chunk Map Canvas / Grid -->
|
||||
{#if mapUrl && !mapFailed}
|
||||
{#key `${serverId}:${mapUrl}`}
|
||||
<VantageMap
|
||||
world={mapUrl}
|
||||
serverId={serverId!}
|
||||
{central}
|
||||
{claims}
|
||||
{dimension}
|
||||
{myGuildId}
|
||||
onselect={(x, z) => { selectedChunk = { x, z }; }}
|
||||
ondimension={(value) => { if (dimension !== value) { dimension = value; claims = []; selectedChunk = null; } }}
|
||||
ondimensions={(value) => { mapDimensions = value; }}
|
||||
oncenterchange={(x, z) => { centerX = x; centerZ = z; }}
|
||||
onerror={(message) => { toast(`Live map unavailable: ${message}. Showing chunk grid.`, 'error'); mapFailed = true; }}
|
||||
/>
|
||||
{#if selectedServer?.map}
|
||||
{#key serverId}
|
||||
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||
{claims} {myGuildId} {player} onpaint={paintChunks}
|
||||
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="grid-wrapper">
|
||||
<div
|
||||
class="chunk-grid"
|
||||
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
|
||||
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
|
||||
>
|
||||
{#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz}
|
||||
{#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx}
|
||||
{@const claim = getClaimAt(cx, cz)}
|
||||
{@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId}
|
||||
{@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)}
|
||||
{@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz}
|
||||
{@const isCenter = cx === 0 && cz === 0}
|
||||
|
||||
<button
|
||||
class="chunk-cell"
|
||||
class:my-claim={isMyClaim}
|
||||
class:other-claim={isOtherClaim}
|
||||
class:selected={isSelected}
|
||||
class:is-center={isCenter}
|
||||
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
|
||||
onmouseleave={() => (hoveredChunk = null)}
|
||||
onclick={() => (selectedChunk = { x: cx, z: cz })}
|
||||
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
|
||||
>
|
||||
{#if isMyClaim}
|
||||
<Shield size={16} class="claim-icon mine" />
|
||||
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
|
||||
{:else if isOtherClaim}
|
||||
<Lock size={15} class="claim-icon rival" />
|
||||
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
|
||||
{:else if isCenter}
|
||||
<span class="spawn-dot" title="World Origin"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Legend & Action Bar -->
|
||||
<div class="map-footer">
|
||||
<div class="info-block">
|
||||
{#if selectedChunk}
|
||||
<div class="selected-details">
|
||||
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
|
||||
<span class="block-range muted tiny">
|
||||
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
|
||||
</span>
|
||||
{#if selectedClaim}
|
||||
<div class="claim-status">
|
||||
{#if myGuildId && selectedClaim.guild_id === myGuildId}
|
||||
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
|
||||
<button
|
||||
class="danger sm action-btn"
|
||||
onclick={handleUnclaim}
|
||||
disabled={claiming}
|
||||
>
|
||||
<Trash2 size={13} /> Unclaim
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
|
||||
<span class="tiny muted protected-note">Grief Protection Active</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="unclaimed-status">
|
||||
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
|
||||
{#if myGuildId}
|
||||
<button
|
||||
class="primary sm action-btn"
|
||||
onclick={handleClaim}
|
||||
disabled={claiming}
|
||||
>
|
||||
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
|
||||
Claim Chunk
|
||||
</button>
|
||||
{:else}
|
||||
<span class="tiny muted">Join or create a guild to claim land</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hoveredChunk}
|
||||
<div class="hover-info">
|
||||
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
|
||||
<span class="muted tiny">Click to view details or claim</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hint-info muted tiny">
|
||||
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
|
||||
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
|
||||
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
|
||||
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
|
||||
{#if painting}<span>Updating claims…</span>{/if}
|
||||
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chunk-map-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
||||
border: 1px solid var(--line);
|
||||
user-select: none;
|
||||
}
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; }
|
||||
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.server-pick { position: relative; display: inline-flex; align-items: center; }
|
||||
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
|
||||
.server-pick select { width: auto; min-width: 10rem; padding: 0.4rem 2rem 0.4rem 2rem; font-size: 0.82rem; font-weight: 560; border-radius: 99rem; }
|
||||
.server-chip { padding: 0.4rem 0.8rem; border-radius: 99rem; font-size: 0.82rem; }
|
||||
.live-chip { border-radius: 99rem; color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
|
||||
.pulse { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent); animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 70% { box-shadow: 0 0 0 0.4rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
||||
.dim-selector { display: flex; background: color-mix(in srgb, var(--bg) 60%, transparent); padding: 0.2rem; border-radius: 99rem; border: 1px solid var(--line); gap: 0.15rem; }
|
||||
.dim-btn { display: flex; align-items: center; gap: 0.35rem; background: transparent; border: none; padding: 0.3rem 0.8rem; font-size: 0.8rem; font-weight: 560; color: var(--muted); border-radius: 99rem; transition: all 0.15s; }
|
||||
.dim-btn:hover:not(:disabled) { color: var(--text); }
|
||||
.dim-btn.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
|
||||
.coord-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.pad { display: inline-flex; padding: 0.15rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 60%, transparent); }
|
||||
.notice { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); font-size: 0.85rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); }
|
||||
.grid-wrapper {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: color-mix(in srgb, var(--bg) 80%, black);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
|
||||
max-height: 24rem;
|
||||
}
|
||||
.chunk-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.chunk-cell {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.chunk-cell:hover {
|
||||
transform: scale(1.08);
|
||||
z-index: 10;
|
||||
border-color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||
}
|
||||
.chunk-cell.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
z-index: 15;
|
||||
}
|
||||
.chunk-cell.is-center::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
}
|
||||
.chunk-cell.my-claim {
|
||||
background: color-mix(in srgb, var(--success) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
.chunk-cell.other-claim {
|
||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
|
||||
}
|
||||
:global(.claim-icon.mine) {
|
||||
color: var(--success);
|
||||
}
|
||||
:global(.claim-icon.rival) {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cell-tag {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-top: 1px;
|
||||
max-width: 34px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.map-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.info-block {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
.selected-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.chunk-title {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.claim-status, .unclaimed-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 99rem;
|
||||
}
|
||||
.badge.mine {
|
||||
background: color-mix(in srgb, var(--success) 18%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.rival {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.badge.unclaimed {
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
.protected-note {
|
||||
font-style: italic;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.legend-box {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.legend-box.mine {
|
||||
background: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
border-color: var(--success);
|
||||
}
|
||||
.legend-box.rival {
|
||||
background: color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.legend-box.wild {
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.claim-map { display: flex; flex-direction: column; gap: 0.8rem; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.toolbar, .group, .footer, .server, .chip, .legend { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.toolbar, .footer { justify-content: space-between; flex-wrap: wrap; }
|
||||
.group { flex-wrap: wrap; }
|
||||
.server select { width: auto; min-width: 10rem; }
|
||||
.dimensions { display: flex; gap: 0.2rem; padding: 0.2rem; background: color-mix(in srgb, var(--bg) 70%, transparent); border-radius: 99px; }
|
||||
.dimensions button { display: flex; gap: 0.3rem; align-items: center; border: 0; border-radius: 99px; background: transparent; color: var(--muted); padding: 0.35rem 0.65rem; font-size: 0.8rem; }
|
||||
.dimensions button.active { background: color-mix(in srgb, var(--accent) 24%, transparent); color: var(--text); }
|
||||
.position, .footer, .notice { color: var(--muted); font-size: 0.8rem; }
|
||||
.notice { min-height: 420px; display: grid; place-items: center; text-align: center; border-radius: 10px; background: #142333; padding: 1rem; }
|
||||
.footer { border-top: 1px solid var(--line); padding-top: 0.65rem; }
|
||||
.footer span { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.legend .mine { background: #4ade80; }.legend .other { background: #fb7185; }
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
|
||||
type Point = { x: number; z: number };
|
||||
|
||||
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||
serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
|
||||
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
|
||||
oncenterchange: (x: number, z: number) => void;
|
||||
} = $props();
|
||||
|
||||
const TILE = 256;
|
||||
let host: HTMLDivElement;
|
||||
let width = $state(800), height = $state(480);
|
||||
let pixelsPerBlock = $state(3);
|
||||
let worldX = $state(8), worldZ = $state(8);
|
||||
let info = $state<{ ready: boolean; message: string; tile_base: string; token: string | null; token_ttl_secs: number; dimensions: Array<{ slug: string; bounds: { min_x: number; max_x: number; min_y: number; max_y: number } | null }> } | null>(null);
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let paint = $state<Point[]>([]);
|
||||
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
|
||||
let broken = $state<Record<string, true>>({});
|
||||
|
||||
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
|
||||
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? null);
|
||||
|
||||
// Tile keys last an hour; renew well before they expire.
|
||||
$effect(() => {
|
||||
const id = serverId;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout>;
|
||||
loading = true; error = ''; broken = {};
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
|
||||
if (!alive) return;
|
||||
info = next; loading = false; error = next.ready ? '' : next.message;
|
||||
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
loading = false; error = typeof e === 'string' ? e : (e as Error)?.message ?? 'Could not load the map';
|
||||
timer = setTimeout(load, 15000);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { alive = false; clearTimeout(timer); };
|
||||
});
|
||||
|
||||
const tileUrl = (x: number, y: number) => `${info!.tile_base}/${slug}/0/${x}/${y}.png?t=${info!.token}`;
|
||||
|
||||
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
|
||||
|
||||
const left = $derived(worldX - width / (2 * pixelsPerBlock));
|
||||
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
|
||||
const visibleTiles = $derived.by(() => {
|
||||
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
|
||||
const out = [];
|
||||
const b = here.bounds;
|
||||
const minX = Math.floor(left / TILE), maxX = Math.floor((left + width / pixelsPerBlock) / TILE);
|
||||
const minZ = Math.floor(top / TILE), maxZ = Math.floor((top + height / pixelsPerBlock) / TILE);
|
||||
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
|
||||
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
|
||||
const url = tileUrl(x, z);
|
||||
if (broken[url]) continue;
|
||||
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
|
||||
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
|
||||
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
|
||||
const playerHere = $derived(player?.dimension === dimension ? player : null);
|
||||
|
||||
function at(e: PointerEvent): Point {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return { x: Math.floor((left + (e.clientX - rect.left) / pixelsPerBlock) / 16),
|
||||
z: Math.floor((top + (e.clientY - rect.top) / pixelsPerBlock) / 16) };
|
||||
}
|
||||
|
||||
function addStroke(next: Point) {
|
||||
const last = pointer?.last ?? next;
|
||||
const steps = Math.max(Math.abs(next.x - last.x), Math.abs(next.z - last.z));
|
||||
const nextPaint = new Map(paint.map((p) => [`${p.x}:${p.z}`, p]));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = Math.round(last.x + (next.x - last.x) * i / Math.max(1, steps));
|
||||
const z = Math.round(last.z + (next.z - last.z) * i / Math.max(1, steps));
|
||||
if (nextPaint.size < 128) nextPaint.set(`${x}:${z}`, { x, z });
|
||||
}
|
||||
paint = [...nextPaint.values()];
|
||||
if (pointer) pointer.last = next;
|
||||
}
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (!info?.ready) return;
|
||||
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
|
||||
e.preventDefault();
|
||||
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
|
||||
pointer = { id: e.pointerId, mode, x: e.clientX, y: e.clientY, last: null };
|
||||
host.setPointerCapture(e.pointerId);
|
||||
if (mode !== 'pan') { paint = []; addStroke(at(e)); }
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
if (!pointer || pointer.id !== e.pointerId) return;
|
||||
if (pointer.mode === 'pan') {
|
||||
worldX -= (e.clientX - pointer.x) / pixelsPerBlock;
|
||||
worldZ -= (e.clientY - pointer.y) / pixelsPerBlock;
|
||||
pointer.x = e.clientX; pointer.y = e.clientY;
|
||||
} else addStroke(at(e));
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
if (!pointer || pointer.id !== e.pointerId) return;
|
||||
const mode = pointer.mode;
|
||||
if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
|
||||
pointer = null;
|
||||
if (mode === 'pan') oncenterchange(Math.floor(worldX / 16), Math.floor(worldZ / 16));
|
||||
else if (paint.length) onpaint(paint, mode);
|
||||
paint = [];
|
||||
}
|
||||
function wheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
pixelsPerBlock = Math.max(1.5, Math.min(6, pixelsPerBlock * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(([entry]) => { width = entry.contentRect.width; height = entry.contentRect.height; });
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="Territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
|
||||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
|
||||
{#each visibleTiles as tile (tile.key)}
|
||||
<img class="tile" src={tile.url} alt="" draggable="false" onerror={() => (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />
|
||||
{/each}
|
||||
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
|
||||
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
|
||||
{#each visibleClaims as claim (claim.id)}
|
||||
<div class="claim" class:mine={claim.guild_id === myGuildId}
|
||||
title="[{claim.chunk_x}, {claim.chunk_z}] {claim.guild_name}"
|
||||
style:left="{(claim.chunk_x * 16 - left) * pixelsPerBlock}px" style:top="{(claim.chunk_z * 16 - top) * pixelsPerBlock}px"
|
||||
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
|
||||
{/each}
|
||||
{#each paint as chunk (`${chunk.x}:${chunk.z}`)}
|
||||
<div class="preview" class:remove={pointer?.mode === 'unclaim'}
|
||||
style:left="{(chunk.x * 16 - left) * pixelsPerBlock}px" style:top="{(chunk.z * 16 - top) * pixelsPerBlock}px"
|
||||
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
|
||||
{/each}
|
||||
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
|
||||
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
|
||||
{#if loading}<div class="message">Loading the map…</div>{/if}
|
||||
{#if error && !loading}<div class="message">{error}</div>{/if}
|
||||
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
|
||||
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terrain { position: relative; height: min(62vh, 640px); min-height: 420px; overflow: hidden; background: #142333; border-radius: 10px; cursor: crosshair; touch-action: none; }
|
||||
.tile { position: absolute; image-rendering: pixelated; pointer-events: none; }
|
||||
.grid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, #ffffff42 1px, transparent 1px), linear-gradient(to bottom, #ffffff42 1px, transparent 1px); }
|
||||
.claim, .preview { position: absolute; pointer-events: none; box-sizing: border-box; }
|
||||
.claim { background: #fb71854a; border: 2px solid #fb7185; }
|
||||
.claim.mine { background: #4ade804a; border-color: #4ade80; }
|
||||
.preview { background: #4ade8090; border: 2px solid #fff; }
|
||||
.preview.remove { background: #fb718590; }
|
||||
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
|
||||
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
|
||||
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
|
||||
.help { left: 10px; }.coords { right: 10px; }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PlayerLink from './PlayerLink.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
|
||||
@@ -213,7 +214,7 @@
|
||||
</span>
|
||||
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
|
||||
<div class="who">
|
||||
<strong>{m?.name ?? 'Former member'}</strong>
|
||||
<strong>{#if m}<PlayerLink uuid={tx.actor_uuid} name={m.name} />{:else}Former member{/if}</strong>
|
||||
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
|
||||
</div>
|
||||
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
|
||||
|
||||
@@ -71,6 +71,15 @@
|
||||
<div class="card col">
|
||||
<strong>Vanilla game settings</strong>
|
||||
<span class="help">Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates.</span>
|
||||
<label class="field">
|
||||
<span class="row">Field of view <span class="spacer"></span><strong>{Math.round(70 + 40 * Number(app.settings.instance_game_options?.[inst.id]?.fov ?? '0'))}°</strong></span>
|
||||
<input type="range" min="-1" max="1" step="0.025" value={app.settings.instance_game_options?.[inst.id]?.fov ?? '0'} oninput={(e) => {
|
||||
app.settings!.instance_game_options[inst.id] ??= {};
|
||||
app.settings!.instance_game_options[inst.id].fov = e.currentTarget.value;
|
||||
invoke('set_instance_fov', { instanceId: inst.id, fov: Number(e.currentTarget.value) }).catch((err) => toast(errorText(err), 'error'));
|
||||
}} />
|
||||
<span class="help">Applied the next time you launch this instance.</span>
|
||||
</label>
|
||||
<span class="tiny muted">{Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance</span>
|
||||
<button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
|
||||
try {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, Globe, Flame, Sparkles, Users, LoaderCircle, RefreshCw, MapPinOff } from '@lucide/svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { abs } from '../lib/store.svelte';
|
||||
import VantageMap from './VantageMap.svelte';
|
||||
|
||||
let { serverId, serverName, onclose }: { serverId: number; serverName: string; onclose: () => void } = $props();
|
||||
|
||||
type Info = {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
message: string;
|
||||
base: string;
|
||||
players: number;
|
||||
dimensions: { id: string; slug: string; label: string; available: boolean }[];
|
||||
};
|
||||
|
||||
let info = $state<Info | null>(null);
|
||||
let slug = $state('overworld');
|
||||
let error = $state('');
|
||||
let failed = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
const icons: Record<string, any> = { overworld: Globe, the_nether: Flame, the_end: Sparkles };
|
||||
const url = $derived(info?.ready ? abs(`${info.base}/${slug}/v1/worlds/default/manifest.json`) : null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
info = await invoke<Info>('get_livemap_info', { serverId });
|
||||
error = '';
|
||||
if (!info.dimensions.find((d) => d.slug === slug)?.available) slug = info.dimensions.find((d) => d.available)?.slug ?? 'overworld';
|
||||
} catch (e: any) {
|
||||
error = typeof e === 'string' ? e : e?.message ?? 'Unable to reach the panel';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
// While the map is still being prepared, keep checking; afterwards only the roster count changes.
|
||||
const t = setInterval(refresh, 15_000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
|
||||
<header class="glass">
|
||||
<div class="title">
|
||||
<span class="live" class:on={info?.ready}></span>
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span class="muted">Live Map{info?.ready ? ` · ${info.players} ${info.players === 1 ? 'player' : 'players'} online` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if info?.ready}
|
||||
<nav class="dims">
|
||||
{#each info.dimensions as d (d.slug)}
|
||||
{@const Icon = icons[d.slug] ?? Globe}
|
||||
<button class="dim" class:on={slug === d.slug} disabled={!d.available} onclick={() => { slug = d.slug; failed = ''; loaded = false; }}>
|
||||
<Icon size={14} /> {d.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{#if info?.ready}<span class="chip players"><Users size={13} /> {info.players}</span>{/if}
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
{#if url && !failed}
|
||||
{#key `${serverId}:${slug}`}
|
||||
<VantageMap
|
||||
world={url}
|
||||
{serverId}
|
||||
central
|
||||
fill
|
||||
onready={() => (loaded = true)}
|
||||
onerror={(m) => (failed = m)}
|
||||
/>
|
||||
{/key}
|
||||
{#if !loaded}<div class="note"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
|
||||
{:else}
|
||||
<div class="note card">
|
||||
{#if failed}
|
||||
<MapPinOff size={30} />
|
||||
<h3>Couldn't load the map</h3>
|
||||
<p class="muted selectable">{failed}</p>
|
||||
<button class="primary" onclick={() => { failed = ''; loaded = false; refresh(); }}><RefreshCw size={15} /> Try again</button>
|
||||
{:else if error}
|
||||
<MapPinOff size={30} />
|
||||
<h3>Live Map unavailable</h3>
|
||||
<p class="muted selectable">{error}</p>
|
||||
<button class="primary" onclick={refresh}><RefreshCw size={15} /> Try again</button>
|
||||
{:else if info}
|
||||
<LoaderCircle class="spin" size={30} />
|
||||
<h3>{info.enabled ? 'The map is being prepared' : 'Live Map is off'}</h3>
|
||||
<p class="muted">{info.message || 'Hang on…'}</p>
|
||||
<p class="tiny muted">This page checks again automatically.</p>
|
||||
{:else}
|
||||
<LoaderCircle class="spin" size={28} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
|
||||
header { display: flex; align-items: center; gap: 1rem; padding: 0.65rem 0.8rem 0.65rem 1rem; background: var(--surface); flex-shrink: 0; }
|
||||
.title { display: flex; align-items: center; gap: 0.75rem; min-width: 0; margin-right: auto; }
|
||||
.title div { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
|
||||
.title strong { font-size: 0.98rem; font-weight: 620; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.title .muted { font-size: 0.78rem; }
|
||||
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 50%, transparent); flex-shrink: 0; }
|
||||
.live.on { background: var(--success); box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--success) 22%, transparent); }
|
||||
.dims { display: flex; gap: 0.3rem; padding: 0.2rem; border-radius: 99px; background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.dim { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.85rem; border-radius: 99px; font-size: 0.82rem; border: 0; background: transparent; color: var(--muted); }
|
||||
.dim:hover:not(:disabled) { color: var(--text); }
|
||||
.dim.on { background: var(--accent); color: #fff; }
|
||||
.dim:disabled { opacity: 0.35; }
|
||||
.players { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.close { flex-shrink: 0; }
|
||||
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #102026; }
|
||||
.stage :global(.vantage-map) { position: absolute; inset: 0; }
|
||||
.note { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; max-width: 26rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 1.6rem 2rem; text-align: center; color: #e5e7eb; }
|
||||
.stage > .note:not(.card) { inset: auto 1rem 1rem auto; margin: 0; flex-direction: row; padding: 0.55rem 0.9rem; border-radius: 99px; background: rgba(9, 18, 22, 0.8); font-size: 0.85rem; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); color: var(--text); }
|
||||
.card h3 { font-size: 1.05rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, RefreshCw } from '@lucide/svelte';
|
||||
import MapViewer from '@scopenet/map/MapViewer.svelte';
|
||||
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { activeAccount, app } from '../lib/store.svelte';
|
||||
import MapPlayerPanel from './MapPlayerPanel.svelte';
|
||||
|
||||
let { serverId, serverName, instanceId, onclose }: { serverId: number; serverName: string; instanceId: string; onclose: () => void } = $props();
|
||||
|
||||
let info = $state<MapInfo | null>(null);
|
||||
let overlay = $state<MapOverlay | null>(null);
|
||||
let error = $state('');
|
||||
let nonce = $state(0);
|
||||
|
||||
const panel = $derived(activeAccount()?.panel_url ?? app.panelUrl);
|
||||
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
|
||||
const avatarUrl = (uuid: string) => (panel ? `${panel}/api/v1/avatar/${uuid}?size=64&v=${app.skinVersion}` : '');
|
||||
|
||||
// The feed reloads the overlay every couple of seconds and renews the tile key before it expires.
|
||||
onMount(() => startFeed({
|
||||
loadInfo: () => invoke<MapInfo>('get_map_info', { serverId }),
|
||||
loadOverlay: () => invoke<MapOverlay>('get_map_overlay', { serverId }),
|
||||
onInfo: (i) => { info = i; error = ''; },
|
||||
onOverlay: (o) => { overlay = o; error = ''; },
|
||||
onError: (m) => { error = m; }
|
||||
}));
|
||||
|
||||
const reload = () => { info = null; overlay = null; nonce++; };
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
|
||||
<header class="glass">
|
||||
<div class="title">
|
||||
<span class="live" class:on={!!info?.ready}></span>
|
||||
<div><strong>{serverName}</strong><span class="muted">Live Map{#if overlay} · {overlay.players.length} online{/if}</span></div>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
{#key nonce}
|
||||
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error} selfUuid={activeAccount()?.uuid ?? null}>
|
||||
{#snippet player(p: LivePlayer, close: () => void)}
|
||||
<MapPlayerPanel {serverId} {instanceId} uuid={p.uuid} nameHint={p.name} onclose={close} />
|
||||
{/snippet}
|
||||
</MapViewer>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
|
||||
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
|
||||
.title { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.title div { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.title .muted { font-size: 0.75rem; }
|
||||
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
|
||||
.grow { flex: 1; }
|
||||
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; --map-accent: var(--accent); }
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, Send, UserPlus, Sparkles, MapPin, LoaderCircle, Shield, Clock, Swords, Star } from '@lucide/svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import PlayerLink from './PlayerLink.svelte';
|
||||
import { activeAccount, toast } from '../lib/store.svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { Guild, GuildMember, UserProfileView } from '../lib/types';
|
||||
|
||||
let { serverId, instanceId, uuid, nameHint, onclose }: { serverId: number; instanceId: string; uuid: string; nameHint: string; onclose: () => void } = $props();
|
||||
|
||||
let profile = $state<UserProfileView | null>(null);
|
||||
let loading = $state(true);
|
||||
let failed = $state('');
|
||||
let message = $state('');
|
||||
let busy = $state('');
|
||||
let myGuild = $state<Guild | null>(null);
|
||||
let iCanInvite = $state(false);
|
||||
|
||||
const me = $derived(activeAccount());
|
||||
// Acting on someone needs a panel account; offline accounts can still look.
|
||||
const signedIn = $derived(me?.kind === 'panel');
|
||||
const isMe = $derived(me?.uuid?.toLowerCase() === uuid.toLowerCase());
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
failed = '';
|
||||
try {
|
||||
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
|
||||
} catch (e: any) {
|
||||
failed = typeof e === 'string' ? e : e?.message ?? 'Could not load this player';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
if (signedIn) {
|
||||
try {
|
||||
myGuild = await invoke<Guild | null>('get_my_guild', { instanceId });
|
||||
if (myGuild) {
|
||||
const members = await invoke<GuildMember[]>('get_guild_members', { guildId: myGuild.id });
|
||||
const mine = members.find((m) => m.uuid.toLowerCase() === me?.uuid?.toLowerCase());
|
||||
iCanInvite = mine?.role === 'leader' || mine?.role === 'officer';
|
||||
}
|
||||
} catch { /* invites just stay off */ }
|
||||
}
|
||||
}
|
||||
|
||||
// A new click on the map loads the new player.
|
||||
$effect(() => { void uuid; message = ''; load(); });
|
||||
onMount(() => { const t = setInterval(() => { if (!busy) load(); }, 20000); return () => clearInterval(t); });
|
||||
|
||||
async function run(kind: string, job: () => Promise<unknown>, done: string) {
|
||||
busy = kind;
|
||||
try { await job(); toast(done, 'ok'); }
|
||||
catch (e: any) { toast(typeof e === 'string' ? e : e?.message ?? 'That did not work', 'error'); }
|
||||
finally { busy = ''; }
|
||||
}
|
||||
|
||||
const tpa = () => run('tpa', () => invoke('map_action', { serverId, action: 'tpa', targetUuid: uuid, text: null }), 'Teleport request sent');
|
||||
const invite = () => run('invite', () => invoke('guild_send_invite', { guildId: myGuild!.id, targetUuid: uuid }), `Invitation sent to ${profile?.username ?? nameHint}`);
|
||||
async function send() {
|
||||
const text = message.trim();
|
||||
if (!text) return;
|
||||
await run('message', () => invoke('map_action', { serverId, action: 'message', targetUuid: uuid, text }), 'Message sent');
|
||||
message = '';
|
||||
}
|
||||
|
||||
const hours = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m`);
|
||||
const name = $derived(profile?.username ?? nameHint ?? 'Player');
|
||||
const online = $derived(!!profile?.online);
|
||||
const kd = $derived.by(() => {
|
||||
const k = Number(profile?.stats?.player_kills ?? 0), d = Number(profile?.stats?.deaths ?? 0);
|
||||
return d === 0 ? (k ? `${k}.0` : '–') : (k / d).toFixed(2);
|
||||
});
|
||||
const canAct = $derived(signedIn && !isMe && online);
|
||||
</script>
|
||||
|
||||
<aside class="panel glass" style:--pa={profile?.accent_color || 'var(--accent)'} aria-label="Player">
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close"><X size={16} /></button>
|
||||
|
||||
{#if loading && !profile}
|
||||
<div class="center"><LoaderCircle class="spin" size={24} /></div>
|
||||
{:else if failed && !profile}
|
||||
<p class="muted pad">{failed}</p>
|
||||
{:else if profile}
|
||||
<div class="head">
|
||||
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
|
||||
<div class="who">
|
||||
<h3><PlayerLink {uuid} {name} /></h3>
|
||||
{#if profile.level_info?.title}<span class="title"><Sparkles size={12} /> {profile.level_info.title}</span>{/if}
|
||||
<span class="presence"><i class:on={online}></i>{online ? 'Online now' : 'Offline'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip lv"><Star size={12} /> Level {profile.level_info?.level ?? 1}</span>
|
||||
{#if profile.guild}<span class="chip guild"><Shield size={12} /> [{profile.guild.tag}] {profile.guild.name}</span>{/if}
|
||||
{#if profile.rank}<span class="chip rank">{profile.rank.display}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="facts">
|
||||
<div><Clock size={13} /><span>Playtime</span><strong>{hours(profile.stats?.playtime_secs ?? 0)}</strong></div>
|
||||
<div><Swords size={13} /><span>K/D</span><strong>{kd}</strong></div>
|
||||
<div><MapPin size={13} /><span>Favourite</span><strong>{profile.favorite_server ?? '–'}</strong></div>
|
||||
<div><Star size={13} /><span>Awards</span><strong>{profile.achievements_count ?? 0}</strong></div>
|
||||
</div>
|
||||
{#if profile.bio}<p class="bio">{profile.bio}</p>{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if isMe}
|
||||
<p class="muted small">This is you.</p>
|
||||
{:else if !signedIn}
|
||||
<p class="muted small">Sign in with a SCOPENET account to send requests and messages.</p>
|
||||
{:else if !online}
|
||||
<p class="muted small">{name} isn't online on this server, so they can't be reached from the map.</p>
|
||||
{/if}
|
||||
|
||||
<button class="primary" disabled={!canAct || !!busy} onclick={tpa}>
|
||||
{#if busy === 'tpa'}<LoaderCircle class="spin" size={14} />{:else}<MapPin size={14} />{/if} Send teleport request
|
||||
</button>
|
||||
|
||||
<div class="msg">
|
||||
<input bind:value={message} placeholder="Message in game…" maxlength="200" disabled={!canAct} onkeydown={(e) => e.key === 'Enter' && send()} />
|
||||
<button disabled={!canAct || !message.trim() || !!busy} onclick={send} aria-label="Send message">
|
||||
{#if busy === 'message'}<LoaderCircle class="spin" size={14} />{:else}<Send size={14} />{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button disabled={!signedIn || isMe || !iCanInvite || !!busy} onclick={invite}
|
||||
title={!myGuild ? 'You are not in a guild here' : !iCanInvite ? 'Only guild leaders and officers can invite' : ''}>
|
||||
{#if busy === 'invite'}<LoaderCircle class="spin" size={14} />{:else}<UserPlus size={14} />{/if}
|
||||
Invite to {myGuild ? `[${myGuild.tag}]` : 'my guild'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panel { position: absolute; top: 0.8rem; right: 0.8rem; bottom: 0.8rem; width: min(20rem, calc(100% - 1.6rem)); display: flex; flex-direction: column; gap: 0.9rem; padding: 1rem; overflow-y: auto; background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.7); animation: slide 0.18s ease; }
|
||||
@keyframes slide { from { transform: translateX(1.5rem); opacity: 0; } }
|
||||
.close { position: absolute; top: 0.5rem; right: 0.5rem; }
|
||||
.center { flex: 1; display: grid; place-items: center; }
|
||||
.pad { padding: 1rem; }
|
||||
.head { display: flex; gap: 0.8rem; align-items: center; padding-right: 1.6rem; }
|
||||
.ring { border: 3px solid var(--pa); border-radius: 28%; overflow: hidden; flex-shrink: 0; }
|
||||
.who { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
||||
.who h3 { margin: 0; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.title { display: inline-flex; align-items: center; gap: 0.3rem; color: #fbbf24; font-size: 0.78rem; }
|
||||
.presence { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--muted); }
|
||||
.presence i { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
|
||||
.presence i.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; font-weight: 650; padding: 0.18rem 0.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.chip.lv { color: var(--pa); }
|
||||
.chip.guild { color: #22d3ee; }
|
||||
.chip.rank { color: #fbbf24; }
|
||||
.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.facts div { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.4rem; align-items: center; padding: 0.5rem 0.6rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 5%, transparent); font-size: 0.72rem; color: var(--muted); }
|
||||
.facts strong { grid-column: 1 / -1; color: var(--text); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bio { margin: 0; font-size: 0.82rem; color: var(--text-2, var(--text)); line-height: 1.4; }
|
||||
.actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto; }
|
||||
.actions button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; }
|
||||
.msg { display: flex; gap: 0.4rem; }
|
||||
.msg input { flex: 1; min-width: 0; }
|
||||
.small { font-size: 0.75rem; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { app } from '../lib/store.svelte';
|
||||
|
||||
// A player's name that opens their profile. Safe to place inside other clickable rows.
|
||||
let { uuid, name = '', class: cls = '', children }: { uuid?: string | null; name?: string; class?: string; children?: Snippet } = $props();
|
||||
const open = (e: Event) => { e.stopPropagation(); if (uuid) app.viewProfileUuid = uuid; };
|
||||
</script>
|
||||
|
||||
{#if uuid}
|
||||
<button type="button" class="plink {cls}" title="View {name || 'player'}'s profile" onclick={open}
|
||||
onkeydown={(e) => e.stopPropagation()}>{#if children}{@render children()}{:else}{name}{/if}</button>
|
||||
{:else}
|
||||
<span class={cls}>{#if children}{@render children()}{:else}{name}{/if}</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.plink { all: unset; cursor: pointer; font: inherit; color: inherit; border-radius: 4px; transition: color 0.12s ease; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: inherit; vertical-align: bottom; }
|
||||
.plink:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.plink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@
|
||||
let editBanner = $state('');
|
||||
let editAccent = $state('');
|
||||
let relationBusy = $state(false);
|
||||
let confirmRemove = $state(false);
|
||||
const swatches = ['#8b6cff', '#22d3ee', '#34d399', '#fbbf24', '#fb7185', '#f472b6', '#60a5fa'];
|
||||
let savingProfile = $state(false);
|
||||
|
||||
@@ -215,7 +216,13 @@
|
||||
<div class="relation-bar">
|
||||
{#if profile.relationship === 'accepted'}
|
||||
<button class="sm primary" onclick={message}><MessageSquare size={14} /> Message</button>
|
||||
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}><UserMinus size={14} /> Remove friend</button>
|
||||
{#if confirmRemove}
|
||||
<span class="tiny muted">Remove {profile.username} from your friends?</span>
|
||||
<button class="sm danger" disabled={relationBusy} onclick={() => { confirmRemove = false; relate('remove'); }}>Yes, remove</button>
|
||||
<button class="sm ghost" onclick={() => (confirmRemove = false)}>Keep</button>
|
||||
{:else}
|
||||
<button class="sm ghost" disabled={relationBusy} onclick={() => (confirmRemove = true)}><UserMinus size={14} /> Remove friend</button>
|
||||
{/if}
|
||||
{:else if profile.relationship === 'pending_incoming'}
|
||||
<span class="tiny muted">{profile.username} wants to be friends</span>
|
||||
<button class="sm primary" disabled={relationBusy} onclick={() => relate('accept')}><UserCheck size={14} /> Accept</button>
|
||||
@@ -275,19 +282,19 @@
|
||||
<!-- Segmented Profile Tabs -->
|
||||
<div class="profile-tabs">
|
||||
<button class:active={activeTab === 'overview'} onclick={() => (activeTab = 'overview')}>
|
||||
<LayoutGrid size={14} /> Overview
|
||||
<LayoutGrid size={14} /> <span>Overview</span>
|
||||
</button>
|
||||
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
|
||||
<MessageSquare size={14} /> Posts & Feed
|
||||
<MessageSquare size={14} /> <span>Posts</span>
|
||||
</button>
|
||||
<button class:active={activeTab === 'progression'} onclick={() => (activeTab = 'progression')}>
|
||||
<Award size={14} /> Levels & Ranks
|
||||
<Award size={14} /> <span>Levels</span>
|
||||
</button>
|
||||
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
|
||||
<Trophy size={14} /> Achievements ({profile.achievements_count ?? profile.achievements?.length ?? 0})
|
||||
<Trophy size={14} /> <span>Awards {profile.achievements_count ?? profile.achievements?.length ?? 0}</span>
|
||||
</button>
|
||||
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
|
||||
<Swords size={14} /> Stats
|
||||
<Swords size={14} /> <span>Stats</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -538,7 +545,7 @@
|
||||
}
|
||||
.profile-modal {
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
max-width: 38rem;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
@@ -661,21 +668,24 @@
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface) 90%, black);
|
||||
padding: 0 1rem;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.profile-tabs button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
gap: 0.3rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.65rem 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 560;
|
||||
color: var(--muted);
|
||||
border-radius: 0;
|
||||
@@ -685,8 +695,10 @@
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--pa, var(--accent));
|
||||
}
|
||||
.profile-tabs { overflow-x: auto; }
|
||||
.profile-tabs button { white-space: nowrap; }
|
||||
.profile-tabs button span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-tabs button :global(svg) { flex-shrink: 0; }
|
||||
@media (max-width: 34rem) { .profile-tabs button :global(svg) { display: none; } }
|
||||
.presence { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
|
||||
.presence.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
|
||||
.chips-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.65rem; bottom: 0.65rem; width: 3.5px; border-radius: 0 3px 3px 0; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
|
||||
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
|
||||
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
|
||||
.inst:hover .tip { opacity: 1; }
|
||||
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
|
||||
.big { width: 2.6rem; height: 2.6rem; position: relative; border-radius: var(--radius-sm); transition: transform 0.12s; }
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
|
||||
.name { gap: 0.5rem; }
|
||||
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name strong { min-width: 0; flex: 0 1 auto; font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
|
||||
.state.online { color: var(--success); }
|
||||
|
||||
@@ -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>
|
||||
@@ -133,14 +133,31 @@ const wallet = {
|
||||
};
|
||||
const mockWallet = () => JSON.parse(JSON.stringify(wallet));
|
||||
|
||||
// `?stress` in the dev URL makes every name, title and description much longer, to check that no screen lets text spill out of its box.
|
||||
const STRESS = typeof location !== 'undefined' && new URLSearchParams(location.search).has('stress');
|
||||
const TEXT_KEYS = /(^|_)(name|title|username|description|motd|message|desc|label|bio|tagline|text|rank|guild)(_|$)/i;
|
||||
function stress(value: unknown, key = ''): unknown {
|
||||
if (Array.isArray(value)) return value.map((v) => stress(v, key));
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stress(v, k)]));
|
||||
if (typeof value === 'string' && TEXT_KEYS.test(key) && /^[A-Za-z][A-Za-z '&:,.\-]{2,60}$/.test(value)) return `${value} With An Extraordinarily Long Unbroken Continuation`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||
const result = await mockInvokeInner(cmd, args);
|
||||
return STRESS ? stress(result) : result;
|
||||
}
|
||||
|
||||
async function mockInvokeInner(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||
await sleep(40);
|
||||
switch (cmd) {
|
||||
case 'get_public_servers':
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_livemap_info':
|
||||
return { enabled: true, ready: false, message: 'The map generator isn\'t set up on the panel yet.', base: '/api/livemap/1', players: 0,
|
||||
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_map_info':
|
||||
return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0,
|
||||
dimensions: [] };
|
||||
case 'get_map_overlay':
|
||||
return { players: [], claims: [], pins: [], updated: null };
|
||||
case 'get_player_stats':
|
||||
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
|
||||
case 'get_leaderboard':
|
||||
@@ -218,6 +235,8 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
||||
return 21;
|
||||
case 'get_my_level':
|
||||
return { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] };
|
||||
case 'get_reward_summaries':
|
||||
return { 'quest:q1': ['$250.00', 'Diamond x3'], 'quest:q2': ['+4 claim chunks'] };
|
||||
case 'get_my_quests':
|
||||
return [
|
||||
{ quest: { id: 'q1', title: 'Deepslate Quarry', description: 'Mine 50 deepslate blocks in the caverns.', quest_type: 'daily', category: 'mining', xp_reward: 120, stat_type: 'mine_deepslate', target_count: 50, icon: 'pickaxe', active: true }, current_count: 35, completed: false, claimed: false, expires_at: '2026-10-01T00:00:00Z' },
|
||||
|
||||
@@ -90,9 +90,17 @@ export async function init() {
|
||||
theme();
|
||||
pickSelected();
|
||||
subscribe();
|
||||
app.ready = true;
|
||||
invoke('show_window').catch(() => {});
|
||||
if (app.panelUrl) await refresh();
|
||||
if (app.panelUrl && !app.manifest) {
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
app.ready = true;
|
||||
}
|
||||
} else {
|
||||
app.ready = true;
|
||||
if (app.panelUrl) await refresh();
|
||||
}
|
||||
if (boot.settings.check_updates && boot.app.repo) {
|
||||
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
|
||||
}
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user