24 Commits
Author SHA1 Message Date
SCOPEDDandClaude ce49447b6e Fix Emails page structuredClone error on load (#5)
Server integrations / Paper and protocol tests (push) Failing after 22s
Server integrations / fabric / 1.20.1 (push) Failing after 6s
Server integrations / fabric / 1.21.1 (push) Failing after 7s
Server integrations / fabric / 26.1.2 (push) Failing after 9s
Server integrations / fabric / 26.2 (push) Failing after 8s
Server integrations / fabric / 26.3 (push) Failing after 7s
Server integrations / forge / 1.20.1 (push) Failing after 5s
Server integrations / forge / 1.21.1 (push) Failing after 6s
Server integrations / forge / 26.2 (push) Failing after 7s
Server integrations / forge / 26.1.2 (push) Failing after 7s
Server integrations / forge / 26.3 (push) Failing after 12s
CI / Web UIs (type-check + build) (panel/web) (push) Canceled after 0s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Canceled after 0s
CI / Docker image builds (push) Canceled after 0s
CI / Rust (fmt, clippy, tests) (push) Canceled after 1m40s
CI / Launcher (Windows build check) (push) Canceled after 1m40s
CI / Web UIs (type-check + build) (launcher) (push) Canceled after 1m44s
Clone from the raw API response instead of the $state proxy.


Claude-Session: https://claude.ai/code/session_01VvKPBTwMK2SLDNUp5SxR7A

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

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src/panel/web
COPY panel/web/package.json panel/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
# The map viewer is shared with the launcher (imported as @scopenet/map).
COPY shared/ /src/shared/
COPY panel/web/ ./
RUN npm run build
+3
View File
@@ -99,6 +99,9 @@ docker-compose.yml Panel deployment
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
- [Development](docs/development.md) — running everything locally, tests
- [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups
-76
View File
@@ -1,76 +0,0 @@
# BlueMap: the 3D map
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for the 3D world map. BlueMap runs on each Minecraft server as a plugin or mod and serves the map on its own web address. SCOPENET adds to it, and the launcher shows it inside the app exactly as a browser would.
## What you get
| On the map | Where it comes from |
|---|---|
| **Guild claims** as coloured regions with the guild's icon and name (one colour per guild, outlines follow the real shape, holes included) | the claim index |
| **Spawn, warps and guild homes** as pins | the server |
| **Homes** as pins (off by default, see privacy) | the server |
| **Markets and shops** as pins that list what is for sale | shop points + the panel's market |
| **Players** with BlueMap's own live markers; **click one** for a side panel | BlueMap + SCOPENET |
The player panel shows level, guild, rank, title, playtime and server level. Inside the launcher it also offers **Send teleport request**, **Send message** and **Invite to my guild** (you need to be signed in with a SCOPENET account, and the other player must be online on that server; inviting needs a leader or officer role). In a plain browser it shows the info and points you to the launcher for the actions.
## Set it up
1. **Install BlueMap** on the server: the Paper plugin, or the Fabric/Forge mod, from <https://bluemap.bluecolored.de/>. Start the server once. BlueMap asks you to accept a download of the Minecraft client files in its config (`core.conf`: `accept-download: true`), then renders the world.
2. **Make its web address reachable.** BlueMap serves on port 8100 by default. Put it behind HTTPS with a reverse proxy (recommended; see below) or open the port.
3. **Install the SCOPENET plugin or Fabric mod** (Paper, or Fabric 1.20.1). It finds BlueMap by itself and adds its markers and script. Nothing else to configure.
4. **Tell the panel the address:** *Servers → your server → Settings → Map address (BlueMap)*, for example `https://map.example.com`. Players then see a **Live Map** button for that server in the launcher.
`bluemap.enabled` (Paper `config.yml`, Fabric `scopenet.properties`) turns the addon off. `bluemap.show-homes` / `bluemap.show_homes` shows every player's homes.
## Shop points (physical shops)
A shop point is a block that opens the market or the server shop when right-clicked. It works with the normal windows (chest window on Paper; the client mod's shop and market windows where installed) and appears as a pin on the map.
```
/market point add <name> [market|shop] (look at a block; needs scopenet.command.market.point, default op)
/market point remove <name>
/market point list
```
`market` opens the player market and lists recent listings on the map pin; `shop` opens the server shop.
## Inside the launcher
The launcher loads your map address in a frame, so everything BlueMap does in a browser works there too. SCOPENET's script on the map page passes player clicks to the launcher, which shows the side panel. A few things make it work or not:
* **Use HTTPS.** Loading an `http://` map inside an HTTPS page can be blocked as mixed content on some systems.
* **Don't forbid framing.** If your reverse proxy sends `X-Frame-Options: DENY` or a `Content-Security-Policy: frame-ancestors` that excludes the launcher, the map won't load in the app. Remove that header for the map address. The launcher's **Browser** button opens the same map in your browser either way.
* The map address is checked by the panel (web address only, no credentials).
## Privacy
* The map is public to anyone who knows its address, like any BlueMap. **Homes are off by default** for that reason. Claims and guild names are visible by design.
* `players.json`, which drives the browser panel, holds only public facts (name, level, title, guild, rank, playtime). Never balances.
* Actions from the map go through the panel, are rate-limited, and need a signed-in account. A message or teleport request reaches only players who are online on that server.
## Reverse proxy example (nginx)
```nginx
server {
server_name map.example.com;
listen 443 ssl;
location / {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection ""; # BlueMap uses server-sent events for live updates
proxy_buffering off;
}
}
```
## How it works (for developers)
* `net.scopenet.core.map` builds the map data without any map software: `ChunkRegions` turns chunks into outlines (tested with an area-conservation check), `MapModel` builds pins and regions and escapes every name, `MapService` gathers it from any thread.
* `integrations/bluemap` (`MapAddon`) is the only code that touches BlueMap's API. It copies `scopenet.js`, `scopenet.css` and the icons into BlueMap's web root, registers them, and refreshes the marker sets every 10 seconds.
* Map actions are queued in the panel (`server_actions`); the server collects them every 3 seconds (`ActionPoller`) and carries them out with its normal rules. Guild invitations are real invitations (`guild_invites`) that the other player accepts in the launcher or with `/guild accept`.
## Not verified yet
The BlueMap addon, the Paper and Fabric wiring, and the launcher's map view have not been compiled or run against a real BlueMap and Minecraft: the build environment cannot download them. The geometry, map model, shop points, action poller and guild-invite commands are unit-tested, and so are the panel endpoints. Build it, and report what BlueMap says in its log and what the map shows.
+2 -3
View File
@@ -23,9 +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 2D world map and claim land in the launcher, install BlueMap on the
game server and configure its public web address in the panel. See
[Live map setup](live-map.md).
Servers draw their own world map and send it to the panel; nothing extra to
install or host. See [The SCOPENET Map](map.md).
The named `panel-data` volume holds the database, uploads, hosted launcher
downloads, and the generated JWT signing key. Preserve that volume when
+39
View File
@@ -0,0 +1,39 @@
# Discord messages, live embeds and emails
## Announcements (Admin panel → Discord → Announcements)
Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it:
- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields.
- The sender's name and picture.
- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot.
- Player names are cleaned so they can't ping anyone or break the formatting.
Switch each message on or off, and press **Save & send a sample** to see it in your channel.
## Live embeds (Discord → Live embeds)
Four boards that the panel posts once and then **edits in place**, so the channel never fills up:
| Embed | Shows | Rows are set by |
|---|---|---|
| **Server status** | Every server: online, players, TPS, version | the line template |
| **Active guilds** | Top guilds with level, members, land and leader | rows shown |
| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown |
| **Richest players** | Top balances of one server economy | economy of, rows shown |
Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it.
## Emails (Admin panel → Emails)
Set up the sender and Resend key under **Settings → Connections** first. Then:
1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`.
2. Check the live **preview**, and **send a test** to yourself.
3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons.
Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account.
## Live map on the landing page
In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again.
-21
View File
@@ -1,21 +0,0 @@
# Live map and land claims
SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for terrain. Install BlueMap on the Minecraft server and make its web app reachable from players' computers. In the panel's **Servers → server settings**, enter the BlueMap **Map address**. The launcher opens the same map in its Live Map window and reads BlueMap's low resolution 2D tile images for the Guilds territory view.
The terrain address must serve BlueMap's `settings.json`, per-map `settings.json`, and low resolution PNG tiles from the same URL path. A URL such as `https://maps.example.com/` works. If BlueMap is hosted below a path, include the trailing slash, for example `https://example.com/bluemap/`.
## Center on the player
Turn on **Live player positions** in the server settings and leave `livemap.enabled: true` in the SCOPENET plugin or mod configuration. The game server sends current positions to the panel. The panel returns each signed-in player only their own position to the launcher. The **Find me** button centers the territory view on it; the blue dot marks their position. Positions expire when the server stops sending updates.
The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable.
## Claim controls
In **Guilds & Territories**, select the game server and dimension. Left click a chunk to claim it for your guild, or drag across chunks to claim an area. Right click or right drag unclaims your guild's chunks. Alt drag pans; the wheel zooms. The overlay shows your guild's claims in green and other guilds' claims in red. The panel checks membership and claim limits for every request; only successful claims appear in the final map state.
BlueMap must have rendered the area to show terrain. Claims can still be managed by the in-game `/claim` and `/unclaim` commands when the web map is unavailable.
## Existing world mirror
Older SCOPENET game integrations uploaded region chunks to the panel under `/api/server/v1/livemap`. The panel retains those ingest endpoints for compatibility with existing servers, but the launcher terrain comes from BlueMap. Current integrations send player positions only.
+48
View File
@@ -0,0 +1,48 @@
# The SCOPENET Map
Every server draws its own world map. There is nothing to install, host or configure besides the plugin or mod you already run: the game server renders its world to small tiles in the background, sends only what changed to the panel, and the launcher and admin panel show them in a fast native map.
What's on it:
- **Terrain** in the look of a Minecraft map, coloured from the real block textures (modded blocks get a matching colour). The Nether is drawn below its ceiling, so you see caves and lava lakes, not bedrock.
- **Guild land** as coloured outlines with the guild's icon and name, holes and all.
- **Spawn, warps, guild homes, markets and shop points** as pins you can click.
- **Players online**, with their head and facing direction. Click one to open the player panel (profile, teleport request, message in game, invite to your guild).
- **Dimensions**: Overworld, Nether and End tabs appear as soon as the server has drawn them.
## Turn it on
It is on by default for every server. To change it:
1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch.
2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read.
The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority.
## In the launcher
Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles.
## In the admin panel
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
## On your public landing page
Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page).
## How it works
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
- Tiles are palette PNGs, so a typical tile is a few kilobytes. Only tiles that changed are uploaded, and a region is redrawn at most every 90 seconds while players are building.
- The panel stores tiles under its data folder (`map/<server id>/…`) and serves them to signed-in players only, with a short-lived key on every address and ETag caching.
- Player positions go up about every two seconds; claims and pins whenever they change.
- A local cache lives next to the plugin (`plugins/SCOPENET/map`) or mod (`config/scopenet/map`) so a restart doesn't redraw anything.
Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the panel. Servers using region files compressed with LZ4 (an opt-in setting in recent Minecraft versions) are not drawn; leave `region-file-compression` on its default.
## Troubleshooting
- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing.
- **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18).
- **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour.
+1 -1
View File
@@ -12,7 +12,7 @@ Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname:
Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`.
The in-app territory view uses the configured BlueMap 2D tile address for each server. It centers on the player's last position when position relay is enabled. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory.
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
+70
View File
@@ -0,0 +1,70 @@
# Rewards, limits and permission nodes
## Rewards beyond XP
Quests, achievements and rank milestones (level rewards) can hand out more than XP and a title. In the admin panel, open any quest, achievement or milestone and use **Extra rewards**:
| Reward | What happens | Where it happens |
|---|---|---|
| **Money** | Credits the balance, once per economy group (or one chosen server) | Panel, instantly |
| **Claim chunks** | Raises the land limit of the player's guild while they are a member | Panel, instantly |
| **Badge** | Shows a badge on their profile | Panel, instantly |
| **Item** | Puts the item in their inventory (anything that doesn't fit drops at their feet). Vanilla ids such as `diamond` or `modid:item` | Game server, when they are online |
| **Permission** | Grants a permission node, permanently or for a number of days | Game server (LuckPerms by default) |
| **Group** | Adds them to a permission group | Game server (LuckPerms by default) |
| **Message** | Sends a chat message | Game server |
| **Command** | Runs a console command with `{player}` and `{uuid}` filled in | Game server, only if the server allows it |
Players see short summaries ("$250.00", "Diamond x3", "+4 claim chunks") next to each quest and achievement in the launcher. Commands are shown only as "A special reward".
Items, permissions, groups and messages are queued in the panel and handed to the game server once the player is online there. Anything that fails (an unknown item, LuckPerms missing) is retried a few times and then kept in the delivery log with the reason. A reward earned on one server can be delivered by whichever server the player is on, unless you pin it to a server.
### Server settings
Paper `config.yml` / Fabric `scopenet.properties`:
```yaml
rewards:
enabled: true
allow-commands: false # let the panel run its own console commands. Leave off unless every panel admin is trusted.
permission-command: "lp user {player} permission set {node} true"
permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}"
group-command: "lp user {player} parent add {group}"
```
Using another permissions plugin? Change the command templates. Placeholders: `{player}`, `{uuid}`, `{node}`, `{value}`, `{duration}`, `{group}`.
## What you can configure
### Per server (plugin/mod config)
| Setting | Meaning |
|---|---|
| `essentials.max-homes` | Homes every player can set |
| `essentials.home-name-max-length` | Longest home name |
| `essentials.max-warps` | Most warps the server may have (0 = no limit) |
| `essentials.tpa-timeout-seconds` | How long a `/tpa` request stays open |
| `essentials.rtp-radius`, `rtp-min-radius`, `rtp-attempts` | Where `/rtp` sends players, and how hard it tries |
| `essentials.cooldowns.<command>` | Seconds between uses of `spawn`, `home`, `back`, `tpa`, `rtp`, `warp` |
| `map.enabled`, `map.show-homes` | The SCOPENET Map and whether homes appear on it |
| `economy.currency-symbol` | The symbol shown next to money |
| `rewards.*` | See above |
| Feature switches | `leveling`, `quests`, `achievements`, `guilds`, `social`, `essentials`, `economy` |
### Network-wide (admin panel → Progression → Economy & guild rules)
Starting balance, chunks a new guild may claim, extra claims per member and per guild level, guild member limit, and market listings per player. These apply on every server and take effect immediately.
## Permission nodes that change limits
Give these with LuckPerms (or any permissions plugin) to ranks:
| Node | Effect |
|---|---|
| `scopenet.homes.<number>` | Homes limit for the player, e.g. `scopenet.homes.10`. The highest one a player has wins and never lowers the base `max-homes` |
| `scopenet.homes.unlimited` | No home limit |
| `scopenet.cooldown.bypass` | Skip every command cooldown |
Operators and anyone with `*` get the top tier on Paper. On Fabric these nodes are off for everyone until a permissions system (such as the LuckPerms mod) grants them; operators still skip cooldowns.
Claim chunks are a guild limit, so they are changed with the network rules above or with **Claim chunks** rewards, not with a permission node.
+32 -10
View File
@@ -21,15 +21,31 @@ social.chat_prefixes=true
essentials.enabled=true
economy.enabled=true
# Send world chunks and player positions to the panel's Live Map
# (also needs Live Map switched on for this server in the panel).
livemap.enabled=true
# Draw this world on the SCOPENET Map: tiles, player positions, guild land and pins go to the panel
# (also needs the map switched on for this server in the panel, which it is by default).
map.enabled=true
# --- Essentials (1.20.1) -----------------------------------------------------
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes,
# scopenet.homes.25 gives 25 (highest wins), scopenet.homes.unlimited removes the limit.
essentials.max_homes=5
essentials.rtp_radius=2500
essentials.home_name_max_length=32
# Most warps the server may have (0 = no limit).
essentials.max_warps=0
essentials.tpa_timeout_seconds=60
# /rtp picks a random safe spot between rtp_min_radius and rtp_radius blocks from 0,0.
essentials.rtp_radius=2500
essentials.rtp_min_radius=0
essentials.rtp_attempts=15
# Seconds between uses (0 = none). Starts when the command actually teleports.
# Players with scopenet.cooldown.bypass skip all of these.
essentials.cooldown.spawn=0
essentials.cooldown.home=0
essentials.cooldown.back=0
essentials.cooldown.tpa=0
essentials.cooldown.rtp=0
essentials.cooldown.warp=0
# --- Economy (1.20.1) --------------------------------------------------------
# Shown next to amounts.
@@ -53,9 +69,15 @@ placeholders.enabled=true
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
clientlink.enabled=true
# --- BlueMap (1.20.1) --------------------------------------------------------
# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a
# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address).
bluemap.enabled=true
# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link.
bluemap.show_homes=false
# --- 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}
+41 -12
View File
@@ -58,15 +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 live player positions to the panel so the launcher can center its
# BlueMap territory view. Configure BlueMap's public address in the panel.
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
@@ -113,15 +136,21 @@ integrations:
report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# 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}.
# ==============================================================================
bluemap:
rewards:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# 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
-16
View File
@@ -1,16 +0,0 @@
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
@@ -1,211 +0,0 @@
package net.scopenet.bluemap;
import com.flowpowered.math.vector.Vector2d;
import com.flowpowered.math.vector.Vector3d;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.BlueMapAPI;
import de.bluecolored.bluemap.api.BlueMapMap;
import de.bluecolored.bluemap.api.markers.Marker;
import de.bluecolored.bluemap.api.markers.MarkerSet;
import de.bluecolored.bluemap.api.markers.POIMarker;
import de.bluecolored.bluemap.api.markers.ShapeMarker;
import de.bluecolored.bluemap.api.math.Color;
import de.bluecolored.bluemap.api.math.Shape;
import net.scopenet.core.map.ChunkRegions;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapModel;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
/**
* Puts SCOPENET on the BlueMap 3D map:
* <ul>
* <li>guild land as coloured regions, with the guild's icon and name,</li>
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
* <li>markets and shops as pins that list what is for sale,</li>
* <li>a script that opens a side panel when you click a player.</li>
* </ul>
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
* nothing if BlueMap isn't installed.
*/
public final class MapAddon {
/** Where the addon gets its data. Both calls must be safe from any thread. */
public interface Feed {
MapData snapshot();
JsonObject playersJson();
}
static final String WEB_DIR = "assets/scopenet";
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
static final float CLAIM_Y = 66f;
private final Feed feed;
private final Logger log;
private ScheduledExecutorService worker;
private volatile boolean enabled;
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
/** Hook into BlueMap. Returns a handle to stop it. */
public static MapAddon start(Feed feed, Logger log) {
MapAddon addon = new MapAddon(feed, log);
BlueMapAPI.onEnable(addon::enable);
BlueMapAPI.onDisable(api -> addon.disable());
return addon;
}
public void stop() { disable(); }
// ---- lifecycle -------------------------------------------------------------------------
private synchronized void enable(BlueMapAPI api) {
try {
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
Files.createDirectories(root);
for (String name : ASSETS) copy(name, root.resolve(name));
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
} catch (IOException | RuntimeException e) {
log.warning("SCOPENET could not add its files to BlueMap: " + e);
return;
}
enabled = true;
worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-bluemap");
t.setDaemon(true);
return t;
});
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
}
private synchronized void disable() {
enabled = false;
if (worker != null) worker.shutdownNow();
worker = null;
}
private static void copy(String name, Path target) throws IOException {
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
if (in == null) throw new IOException("missing resource " + name);
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// ---- refresh ---------------------------------------------------------------------------
private void refresh(BlueMapAPI api) {
if (!enabled) return;
try {
MapData data = feed.snapshot();
writePlayers(api);
for (BlueMapMap map : api.getMaps()) update(map, data);
} catch (RuntimeException | IOException e) {
log.fine("SCOPENET map refresh failed: " + e);
}
}
private void writePlayers(BlueMapAPI api) throws IOException {
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
Path tmp = file.resolveSibling("players.json.tmp");
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
private void update(BlueMapMap map, MapData data) {
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
}
/** Replace a marker set's contents in place, so the map doesn't flicker. */
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
wanted.forEach(set::put);
}
// ---- markers ---------------------------------------------------------------------------
private static Shape shape(ChunkRegions.Ring ring) {
List<Vector2d> points = new ArrayList<>();
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
return new Shape(points);
}
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
ShapeMarker marker = ShapeMarker.builder()
.label(c.name())
.shape(shape(c.outer()), CLAIM_Y)
.holes(holes)
.lineColor(new Color(c.color(), 0.95f))
.fillColor(new Color(c.color(), 0.28f))
.lineWidth(3)
.depthTestEnabled(false)
.detail(c.detail())
.build();
out.put("claim-" + c.id(), marker);
}
return out;
}
/** One icon and name per area, in the middle of it, fading out when you are far away. */
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
POIMarker.Builder builder = POIMarker.builder()
.label(c.name() + " [" + c.tag() + "]")
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
.styleClasses("scopenet-guild-icon")
.maxDistance(1600)
.detail(c.detail());
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
out.put("guild-" + c.id(), builder.build());
}
return out;
}
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.Pin p : data.pins()) {
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
if (!p.dimension().equals(dimension) || isShop != shops) continue;
String icon = switch (p.kind()) {
case "spawn" -> "spawn.svg";
case "warp" -> "warp.svg";
case "home" -> "home.svg";
case "guild_home" -> "guild-home.svg";
case "market" -> "market.svg";
default -> "shop.svg";
};
out.put(p.id(), POIMarker.builder()
.label(p.label())
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
.icon(WEB_DIR + "/" + icon, 16, 16)
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
.maxDistance(2400)
.detail(p.detail())
.build());
}
return out;
}
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 243 B

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

Before

Width:  |  Height:  |  Size: 288 B

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

Before

Width:  |  Height:  |  Size: 229 B

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

Before

Width:  |  Height:  |  Size: 308 B

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

Before

Width:  |  Height:  |  Size: 387 B

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

Before

Width:  |  Height:  |  Size: 270 B

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

Before

Width:  |  Height:  |  Size: 290 B

@@ -12,6 +12,7 @@ public final class CommandSet {
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;
@@ -22,7 +23,8 @@ public final class CommandSet {
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
economyCommands = economy;
guildCommands = guilds;
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
add(env, 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());
}
@@ -55,6 +57,29 @@ public final class CommandSet {
/** 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(); }
@@ -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());
@@ -161,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.");
@@ -190,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
@@ -223,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();
@@ -242,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);
}
@@ -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; }
}
@@ -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}");
}
}
@@ -52,7 +52,7 @@ public final class MapModel {
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
}
/** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
/** 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";
@@ -0,0 +1,81 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Locale;
/**
* The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text
* lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup.
*/
public final class MapOverlay {
private MapOverlay() {}
public static JsonObject toJson(MapData data) {
JsonArray claims = new JsonArray();
for (MapData.ClaimRegion c : data.claims()) {
JsonObject o = new JsonObject();
o.addProperty("id", c.id());
o.addProperty("guild_id", c.guildId());
o.addProperty("name", c.name());
o.addProperty("tag", c.tag());
o.addProperty("icon", c.icon());
o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF));
o.addProperty("dimension", c.dimension());
o.addProperty("chunks", c.chunks());
o.addProperty("label_x", c.labelX());
o.addProperty("label_z", c.labelZ());
o.add("outer", ring(c.outer()));
JsonArray holes = new JsonArray();
for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole));
o.add("holes", holes);
o.add("lines", lines(c.detail()));
claims.add(o);
}
JsonArray pins = new JsonArray();
for (MapData.Pin p : data.pins()) {
JsonObject o = new JsonObject();
o.addProperty("id", p.id());
o.addProperty("kind", p.kind());
o.addProperty("label", p.label());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.add("lines", lines(p.detail()));
pins.add(o);
}
JsonObject root = new JsonObject();
root.add("claims", claims);
root.add("pins", pins);
return root;
}
private static JsonArray ring(ChunkRegions.Ring ring) {
JsonArray out = new JsonArray();
for (ChunkRegions.Point p : ring.points()) {
JsonArray pair = new JsonArray();
pair.add(p.x());
pair.add(p.z());
out.add(pair);
}
return out;
}
/** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */
static JsonArray lines(String html) {
String text = html == null ? "" : html
.replaceAll("(?i)<br\\s*/?>|</div>|</li>|</ul>|<ul[^>]*>", "\n")
.replaceAll("<[^>]*>", "");
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.replace("&middot;", "·").replace("&amp;", "&");
JsonArray out = new JsonArray();
for (String line : text.split("\n")) {
String t = line.trim();
if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t);
}
return out;
}
}
@@ -9,7 +9,7 @@ import net.scopenet.integration.ClaimIndex;
import java.util.*;
/**
* Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every
* 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 {
@@ -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;
@@ -144,17 +145,34 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler;
}
/** Start sending player positions for the in-app BlueMap territory view. */
public void enableLiveMap() {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client);
liveMap.start();
/**
* 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 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 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) {
@@ -173,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,70 +0,0 @@
package net.scopenet.integration;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private final PanelClient client;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "scopenet-positions");
thread.setDaemon(true);
return thread;
});
private volatile List<Player> players = List.of();
private volatile boolean changed;
private volatile boolean closed;
private long lastSent;
public LiveMapSync(PanelClient client) { this.client = client; }
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
changed = true;
}
}
private void sendSafely() {
if (closed) return;
long now = System.nanoTime();
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject entry = new JsonObject();
entry.addProperty("uuid", p.uuid());
entry.addProperty("name", p.name());
entry.addProperty("dimension", p.dimension());
entry.addProperty("x", p.x());
entry.addProperty("y", p.y());
entry.addProperty("z", p.z());
entry.addProperty("yaw", p.yaw());
entry.addProperty("pitch", p.pitch());
list.add(entry);
}
JsonObject body = new JsonObject();
body.add("players", list);
try {
client.post("livemap/players", body);
changed = false;
lastSent = now;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// Keep the latest snapshot and retry on the next interval.
}
}
@Override public void close() {
closed = true;
worker.shutdownNow();
}
}
@@ -107,12 +107,12 @@ public record Settings(
);
}
/** Local opt-out for the central live map (the panel also has a per-server switch). */
public static boolean liveMapEnabled(Path path) {
/** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
public static boolean mapEnabled(Path path) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
Properties properties = new Properties();
properties.load(reader);
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true")));
} catch (IOException e) {
return true;
}
@@ -139,9 +139,33 @@ public record Settings(
essentials.enabled=true
economy.enabled=true
# Send world chunks and player positions to the panel's central Live Map.
# It only runs when Live Map is also enabled for this server in the panel.
livemap.enabled=true
# Essentials limits. Players can exceed max_homes with the permission scopenet.homes.<number>
# (or scopenet.homes.unlimited). Cooldowns are seconds, 0 = none; scopenet.cooldown.bypass skips them.
essentials.max_homes=5
essentials.home_name_max_length=32
essentials.max_warps=0
essentials.tpa_timeout_seconds=60
essentials.rtp_radius=2500
essentials.rtp_min_radius=0
essentials.rtp_attempts=15
essentials.cooldown.spawn=0
essentials.cooldown.home=0
essentials.cooldown.back=0
essentials.cooldown.tpa=0
essentials.cooldown.rtp=0
essentials.cooldown.warp=0
# Rewards from quests, achievements and rank milestones (items, permissions, groups, messages).
# allow_commands lets the panel run its own console commands as rewards: leave off unless you trust every panel admin.
rewards.enabled=true
rewards.allow_commands=false
rewards.permission_command=lp user {player} permission set {node} true
rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration}
rewards.group_command=lp user {player} parent add {group}
# Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
# It only runs when the map is also enabled for this server in the panel.
map.enabled=true
""";
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
}
@@ -0,0 +1,212 @@
package net.scopenet.worldmap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static net.scopenet.worldmap.MapColors.*;
/**
* Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by
* name keywords, then falls back to a stable neutral colour so the same block always looks the same.
*/
public final class BlockColors {
private BlockColors() {}
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
private static final Set<String> CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane",
"tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot",
"moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air");
private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"};
private static final Set<String> WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass");
private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan",
"purple", "blue", "brown", "green", "red", "black"};
private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE,
BROWN, GREEN, RED, BLACK};
private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51};
private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"};
private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_",
"infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"};
private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign",
"_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"};
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
/** Water is special-cased by the renderer, which shades it by depth. */
public static final int WATER_COLOR = 0xFF3F76E4;
private static final Map<String, Integer> TEXTURES = loadTextures();
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
/**
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
*/
public static int of(String blockName) {
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
}
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
public static int mapColor(String blockName) {
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
}
private static int resolve(String full) {
int id = mapColor(full);
if (id == 0) return 0;
if (id == WATER) return WATER_COLOR;
String n = plain(full);
Integer exact = TEXTURES.get(n);
if (exact != null) return exact;
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (!changed) break;
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
Integer hit = TEXTURES.get(candidate);
if (hit != null) return hit;
}
}
return MapColors.base(id);
}
private static String plain(String full) {
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
}
private static Map<String, Integer> loadTextures() {
Map<String, Integer> out = new java.util.HashMap<>();
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
if (in == null) return out;
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
int comma = line.indexOf(',');
if (comma <= 0 || comma + 7 > line.length()) continue;
try {
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
} catch (NumberFormatException ignored) {
// a malformed row just falls back to the map colour table
}
}
} catch (java.io.IOException ignored) {
// no texture table: the map colour table covers everything
}
return out;
}
private static int classify(String full) {
String n = plain(full);
int c = lookup(n);
if (c >= 0) return c;
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (changed && (c = lookup(s)) >= 0) return c;
}
return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)];
}
/** The colour for a plain name, or -1 when nothing matches. */
private static int lookup(String n) {
if (CLEAR.contains(n)) return NONE;
for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE;
if (WATERY.contains(n)) return WATER;
if (n.equals("lava")) return FIRE;
for (int i = 0; i < DYES.length; i++) {
String d = DYES[i] + "_";
if (!n.startsWith(d)) continue;
String rest = n.substring(d.length());
if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i];
for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i];
}
if (n.equals("terracotta")) return ORANGE;
if (n.equals("grass_block") || n.equals("slime_block")) return GRASS;
if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE
: n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1;
if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE
: n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1;
if (n.contains("deepslate")) return DEEPSLATE;
if (n.contains("tuff")) return TERRACOTTA_GRAY;
if (n.contains("calcite")) return TERRACOTTA_WHITE;
if (n.contains("dripstone")) return TERRACOTTA_BROWN;
if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK;
if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER;
if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED;
if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN;
if (n.equals("glowstone")) return SAND;
if (n.equals("bedrock")) return STONE;
if (n.contains("red_sand")) return ORANGE;
if (n.contains("end_stone")) return SAND;
if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND;
if (n.contains("gravel")) return STONE;
if (n.contains("podzol")) return PODZOL;
if (n.contains("mycelium")) return PURPLE;
if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN;
if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT;
if (n.equals("clay")) return CLAY;
if (n.contains("snow")) return SNOW;
if (n.equals("ice") || n.endsWith("_ice")) return ICE;
if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM
: n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE;
if (n.equals("raw_iron_block")) return RAW_IRON;
if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD;
if (n.equals("raw_copper_block")) return ORANGE;
if (n.equals("diamond_block")) return DIAMOND;
if (n.equals("emerald_block")) return EMERALD;
if (n.equals("lapis_block")) return LAPIS;
if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE;
if (n.contains("amethyst")) return PURPLE;
if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ;
if (n.contains("andesite")) return STONE;
if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA;
if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND;
if (n.contains("prismarine")) return CYAN;
if (n.contains("_ore")) return STONE;
if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron")
|| n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL;
if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer")
|| n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE;
if (n.equals("hay_block")) return YELLOW;
if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW;
if (n.contains("melon")) return LIGHT_GREEN;
if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE;
if (n.equals("glow_lichen")) return GLOW_LICHEN;
if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN;
if (n.contains("red_mushroom")) return RED;
if (n.contains("brown_mushroom")) return DIRT;
if (n.equals("mushroom_stem")) return WOOL;
if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK;
if (n.equals("beacon")) return DIAMOND;
if (n.contains("cherry_leaves")) return PINK;
if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea")
|| n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip")
|| n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet")
|| n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony")
|| n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes")
|| n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush")
|| n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT;
if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD;
if (n.contains("mangrove")) return RED;
if (n.contains("spruce")) return PODZOL;
if (n.contains("birch")) return SAND;
if (n.contains("jungle")) return DIRT;
if (n.contains("acacia")) return ORANGE;
if (n.contains("cherry")) return TERRACOTTA_WHITE;
if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest")
|| n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom")
|| n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD;
if (n.contains("coal")) return BLACK;
if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL;
if (n.contains("wool") || n.contains("carpet")) return WOOL;
return -1;
}
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
}
@@ -0,0 +1,106 @@
package net.scopenet.worldmap;
import java.util.ArrayList;
import java.util.List;
/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */
public final class ChunkSampler {
private ChunkSampler() {}
public enum Kind { OVERWORLD, NETHER, END }
/** Per-column results, indexed {@code z * 16 + x}. */
public static final class Columns {
public final int[] color = new int[256];
public final int[] height = new int[256];
/** For water: how many blocks of water sit above the floor. */
public final int[] depth = new int[256];
}
private record Section(int y, int[] colors, long[] data, int bits) {
int colorAt(int x, int y, int z) {
if (data == null) return colors[0];
int index = y << 8 | z << 4 | x;
int perLong = 64 / bits;
int word = index / perLong;
if (word >= data.length) return 0;
int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1));
return v < colors.length ? colors[v] : 0;
}
}
/** True if the chunk was fully generated and could be read (the columns are then filled in). */
public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) {
String status = chunk.string("Status", "minecraft:full");
if (!status.endsWith("full")) return false;
List<Section> sections = new ArrayList<>();
for (Object o : chunk.list("sections")) {
if (!(o instanceof Nbt.Compound s)) continue;
Nbt.Compound states = s.compound("block_states");
if (states == null) continue;
List<Object> palette = states.list("palette");
if (palette.isEmpty()) continue;
int[] colors = new int[palette.size()];
for (int i = 0; i < colors.length; i++) {
colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0;
}
long[] data = states.longs("data");
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1));
sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits));
}
if (sections.isEmpty()) return false;
sections.sort((a, b) -> Integer.compare(b.y(), a.y()));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int i = z * 16 + x;
out.color[i] = 0;
out.height[i] = 0;
out.depth[i] = 0;
if (kind == Kind.NETHER) column(sections, x, z, out, i, true);
else column(sections, x, z, out, i, false);
}
}
return true;
}
private static void column(List<Section> sections, int x, int z, Columns out, int i, boolean roofed) {
boolean pastRoof = !roofed;
int roofColor = 0, roofY = 0;
for (int si = 0; si < sections.size(); si++) {
Section sec = sections.get(si);
for (int ly = 15; ly >= 0; ly--) {
int worldY = sec.y() * 16 + ly;
if (roofed && worldY > 126) continue;
int c = sec.colorAt(x, ly, z);
if (!pastRoof) {
// Under the nether ceiling: skip solid rock until the first open air.
if (c == 0) pastRoof = true;
else if (roofColor == 0) { roofColor = c; roofY = worldY; }
continue;
}
if (c == 0) continue;
out.color[i] = c;
out.height[i] = worldY;
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
return;
}
}
if (roofed && roofColor != 0) { // a solid column: show the ceiling instead
out.color[i] = roofColor;
out.height[i] = roofY;
}
}
private static int waterDepth(List<Section> sections, int si, int ly, int x, int z) {
int depth = 1;
for (int s = si; s < sections.size(); s++) {
Section sec = sections.get(s);
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
if (++depth >= 64) return depth;
}
}
return depth;
}
}
@@ -0,0 +1,38 @@
package net.scopenet.worldmap;
/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */
public final class MapColors {
private MapColors() {}
public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10,
STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19,
PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29,
GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37,
TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52,
CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58,
DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61;
private static final int[] RGB = {
0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070,
0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99,
0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200,
0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C,
0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464,
0xD8AF93, 0x7FA796};
public static final int COUNT = RGB.length;
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
private static final int[] SHADE = {180, 220, 255, 135};
/** The colour's base RGB (opaque ARGB), or 0 for none. */
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
public static int argb(int id, int shade) {
if (id <= 0 || id >= COUNT) return 0;
int base = RGB[id], m = SHADE[shade & 3];
int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255;
return 0xFF000000 | r << 16 | g << 8 | b;
}
}
@@ -0,0 +1,80 @@
package net.scopenet.worldmap;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */
public final class Nbt {
private Nbt() {}
/** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */
public static final class Compound extends HashMap<String, Object> {
public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; }
@SuppressWarnings("unchecked")
public List<Object> list(String key) { return get(key) instanceof List<?> l ? (List<Object>) l : List.of(); }
public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; }
public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; }
public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; }
}
private static final int MAX_DEPTH = 64;
private static final int MAX_ARRAY = 8 * 1024 * 1024;
/** Reads one named root compound (the form chunks are stored in). */
public static Compound readRoot(DataInputStream in) throws IOException {
byte type = in.readByte();
if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type);
in.readUTF(); // root name, usually empty
return readCompound(in, 0);
}
private static Compound readCompound(DataInputStream in, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
Compound out = new Compound();
while (true) {
byte type = in.readByte();
if (type == 0) return out;
String name = in.readUTF();
out.put(name, readValue(in, type, depth + 1));
}
}
private static Object readValue(DataInputStream in, byte type, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
switch (type) {
case 1: return in.readByte();
case 2: return in.readShort();
case 3: return in.readInt();
case 4: return in.readLong();
case 5: return in.readFloat();
case 6: return in.readDouble();
case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; }
case 8: return in.readUTF();
case 9: {
byte elementType = in.readByte();
int n = length(in);
List<Object> list = new ArrayList<>(Math.min(n, 4096));
for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1));
return list;
}
case 10: return readCompound(in, depth);
case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; }
case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; }
default: throw new IOException("unknown NBT tag " + type);
}
}
private static int length(DataInputStream in) throws IOException {
int n = in.readInt();
if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n);
return n;
}
}
@@ -0,0 +1,146 @@
package net.scopenet.worldmap;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */
public final class Png {
private Png() {}
/**
* Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
*/
public static byte[] encode(int[] argb, int width, int height) {
argb = limitColors(argb);
Map<Integer, Integer> palette = new LinkedHashMap<>();
for (int c : argb) {
int key = (c >>> 24) == 0 ? 0 : c;
if (!palette.containsKey(key)) {
palette.put(key, palette.size());
if (palette.size() > 256) return rgba(argb, width, height);
}
}
int n = palette.size();
int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8;
int rowBytes = (width * depth + 7) / 8;
byte[] raw = new byte[height * (1 + rowBytes)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
int idx = palette.get((c >>> 24) == 0 ? 0 : c);
if (depth == 8) raw[p + x] = (byte) idx;
else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8));
}
p += rowBytes;
}
byte[] plte = new byte[n * 3];
List<Integer> alpha = new ArrayList<>();
for (var e : palette.entrySet()) {
int c = e.getKey(), i = e.getValue();
plte[i * 3] = (byte) (c >> 16);
plte[i * 3 + 1] = (byte) (c >> 8);
plte[i * 3 + 2] = (byte) c;
alpha.add(c == 0 ? 0 : c >>> 24);
}
int last = -1;
for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i;
byte[] trns = new byte[last + 1];
for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i);
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "PLTE", plte);
if (trns.length > 0) chunk(out, "tRNS", trns);
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
/**
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
*/
static int[] limitColors(int[] argb) {
Map<Integer, Integer> counts = new HashMap<>();
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
if (counts.size() <= 255) return argb;
List<Integer> keep = new ArrayList<>(counts.keySet());
keep.sort((a, b) -> counts.get(b) - counts.get(a));
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
Set<Integer> kept = new HashSet<>(top);
Map<Integer, Integer> remap = new HashMap<>();
int[] out = argb.clone();
for (int i = 0; i < out.length; i++) {
int c = out[i];
if ((c >>> 24) == 0 || kept.contains(c)) continue;
out[i] = remap.computeIfAbsent(c, k -> {
int best = top.get(0), bestD = Integer.MAX_VALUE;
for (int q : top) {
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
if (d < bestD) { bestD = d; best = q; }
}
return best;
});
}
return out;
}
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
private static byte[] deflate(byte[] raw) {
Deflater deflater = new Deflater(9);
deflater.setInput(raw);
deflater.finish();
ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64);
byte[] buf = new byte[16384];
while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf));
deflater.end();
return z.toByteArray();
}
private static byte[] rgba(int[] argb, int width, int height) {
byte[] raw = new byte[height * (1 + width * 4)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
raw[p++] = (byte) (c >> 16);
raw[p++] = (byte) (c >> 8);
raw[p++] = (byte) c;
raw[p++] = (byte) (c >>> 24);
}
}
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
private static void chunk(ByteArrayOutputStream out, String type, byte[] data) {
out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array());
byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
out.writeBytes(t);
out.writeBytes(data);
CRC32 crc = new CRC32();
crc.update(t);
crc.update(data);
out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array());
}
}
@@ -0,0 +1,84 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
/**
* Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running:
* a chunk caught mid-write simply fails to read and is retried on the next pass.
*/
public final class RegionFile implements AutoCloseable {
private static final int SECTOR = 4096;
private final FileChannel channel;
private final int[] locations = new int[1024];
private final int[] timestamps = new int[1024];
public RegionFile(Path file) throws IOException {
channel = FileChannel.open(file, StandardOpenOption.READ);
try {
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (channel.read(header) < 0) break;
header.flip();
if (header.remaining() == 2 * SECTOR) {
header.asIntBuffer().get(locations, 0, 1024);
header.position(SECTOR);
header.asIntBuffer().get(timestamps, 0, 1024);
}
} catch (IOException | RuntimeException e) {
channel.close();
throw e;
}
}
public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; }
/** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */
public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; }
/**
* The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read.
* Region files written with LZ4 (a server option since 1.20.5) are skipped.
*/
public Nbt.Compound read(int index) {
int loc = locations[index];
if (loc == 0) return null;
long offset = (long) (loc >>> 8) * SECTOR;
int sectors = loc & 0xFF;
if (offset < 2 * SECTOR || sectors == 0) return null;
try {
ByteBuffer head = ByteBuffer.allocate(5);
if (read(head, offset) < 5) return null;
head.flip();
int length = head.getInt();
int type = head.get() & 0xFF;
if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file
ByteBuffer body = ByteBuffer.allocate(length - 1);
if (read(body, offset + 5) < length - 1) return null;
InputStream raw = new ByteArrayInputStream(body.array());
InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw;
try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) {
return Nbt.readRoot(in);
}
} catch (IOException | RuntimeException e) {
return null;
}
}
private int read(ByteBuffer buffer, long position) throws IOException {
int total = 0;
while (buffer.hasRemaining()) {
int n = channel.read(buffer, position + total);
if (n < 0) break;
total += n;
}
return total;
}
@Override public void close() throws IOException { channel.close(); }
}
@@ -0,0 +1,80 @@
package net.scopenet.worldmap;
import java.io.IOException;
import java.nio.file.Path;
/**
* Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded
* by the slope towards the north, and water shaded by depth.
*/
public final class RegionRenderer {
private RegionRenderer() {}
public static final int TILE = 256;
private static final int SIDE = 512;
/** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */
public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException {
int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE];
ChunkSampler.Columns cols = new ChunkSampler.Columns();
boolean any = false;
try (RegionFile region = new RegionFile(file)) {
for (int i = 0; i < 1024; i++) {
if (region.timestamp(i) == 0) continue;
Nbt.Compound chunk = region.read(i);
if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue;
int cx = (i & 31) * 16, cz = (i >> 5) * 16;
for (int z = 0; z < 16; z++) {
int dst = (cz + z) * SIDE + cx;
System.arraycopy(cols.color, z * 16, colors, dst, 16);
System.arraycopy(cols.height, z * 16, heights, dst, 16);
System.arraycopy(cols.depth, z * 16, depths, dst, 16);
}
any = true;
}
}
if (!any) return null;
int[] pixels = shade(colors, heights, depths);
int[][] tiles = new int[4][TILE * TILE];
for (int ty = 0; ty < 2; ty++) {
for (int tx = 0; tx < 2; tx++) {
int[] tile = tiles[tx + ty * 2];
for (int row = 0; row < TILE; row++) {
System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE);
}
}
}
return tiles;
}
/**
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
* Colours are the blocks' own, so they stay in few steps and compress well.
*/
static int[] shade(int[] colors, int[] heights, int[] depths) {
int[] out = new int[SIDE * SIDE];
for (int z = 0; z < SIDE; z++) {
for (int x = 0; x < SIDE; x++) {
int i = z * SIDE + x;
int c = colors[i];
if (c == 0) continue;
int percent;
if (c == BlockColors.WATER_COLOR) {
percent = 108 - Math.min(depths[i], 14) * 4;
} else {
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
int diff = heights[i] - north;
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
}
out[i] = scale(c, percent);
}
}
return out;
}
static int scale(int argb, int percent) {
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
b = Math.min(255, (argb & 0xFF) * percent / 100);
return 0xFF000000 | r << 16 | g << 8 | b;
}
}
@@ -0,0 +1,125 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per
* block; every level up halves the resolution, down to {@code maxZoom}.
*/
public final class TileStore {
public record Key(String dim, int zoom, int x, int y) {
@Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; }
public static Key parse(String s) {
String[] p = s.split("/");
return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]));
}
}
private static final int SIZE = RegionRenderer.TILE;
private static final int PIXELS = SIZE * SIZE;
private final Path root;
private final Map<Key, int[]> memory = new LinkedHashMap<>(64, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<Key, int[]> e) { return size() > 96; }
};
public TileStore(Path root) { this.root = root; }
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
/** The tile's pixels, or null if it was never rendered. */
public synchronized int[] load(Key k) {
int[] cached = memory.get(k);
if (cached != null) return cached;
Path f = file(k);
if (!Files.isRegularFile(f)) return null;
try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
byte[] raw = in.readAllBytes();
if (raw.length != PIXELS * 4) return null;
int[] pixels = new int[PIXELS];
ByteBuffer.wrap(raw).asIntBuffer().get(pixels);
memory.put(k, pixels);
return pixels;
} catch (IOException e) {
return null;
}
}
public synchronized void save(Key k, int[] pixels) throws IOException {
ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4);
raw.asIntBuffer().put(pixels);
Path f = file(k);
Files.createDirectories(f.getParent());
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) {
out.write(raw.array());
}
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
memory.put(k, pixels);
}
public synchronized void clear() throws IOException {
memory.clear();
if (!Files.isDirectory(root)) return;
try (var walk = Files.walk(root)) {
for (Path p : (Iterable<Path>) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p);
}
}
public static boolean blank(int[] pixels) {
for (int p : pixels) if ((p >>> 24) != 0) return false;
return true;
}
/** Stores a freshly rendered tile and returns true if it differs from what was there. */
public boolean update(Key k, int[] pixels) throws IOException {
int[] old = load(k);
if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false;
save(k, pixels);
return true;
}
/**
* Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null.
*/
public Key rebuildParent(Key child) throws IOException {
int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1;
int[] out = new int[PIXELS];
for (int qy = 0; qy < 2; qy++) {
for (int qx = 0; qx < 2; qx++) {
int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy));
if (src == null) continue;
int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2);
for (int y = 0; y < SIZE / 2; y++) {
for (int x = 0; x < SIZE / 2; x++) {
out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1],
src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]);
}
}
}
}
Key parent = new Key(child.dim(), pz, px, py);
return update(parent, out) ? parent : null;
}
/**
* The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps
* zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well.
*/
static int average(int a, int b, int c, int d) {
int[] px = {a, b, c, d};
int best = 0, bestCount = 0;
for (int i = 0; i < 4; i++) {
if ((px[i] >>> 24) == 0) continue;
int count = 0;
for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++;
if (count > bestCount) { best = px[i]; bestCount = count; }
}
return best;
}
}
@@ -0,0 +1,400 @@
package net.scopenet.worldmap;
import com.google.gson.*;
import net.scopenet.integration.PanelClient;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
/**
* SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one
* region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along.
* Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}.
*/
public final class WorldMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private record Dim(String id, String dir, ChunkSampler.Kind kind) {}
private static final Dim[] DIMS = {
new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD),
new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER),
new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)};
private static final int BATCH_BYTES = 3 * 1024 * 1024;
private static final long RERENDER_GAP_MS = 90_000;
private static final long COARSE_UPLOAD_GAP_MS = 60_000;
private static final class State {
long epoch = -1;
/** "dim/x.z" -> file mtime (ms) at the time it was rendered. */
Map<String, Long> regions = new HashMap<>();
/** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */
Set<String> dirty = new HashSet<>();
}
private final PanelClient client;
private final Path worldRoot;
private final Path cacheDir;
private final Consumer<String> log;
private final Gson gson = new Gson();
private final TileStore store;
private final Object wake = new Object();
private final Thread thread;
private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-map-live");
t.setDaemon(true);
return t;
});
private State state = new State();
private final Map<String, Long> renderedAt = new HashMap<>();
private volatile boolean closed;
private volatile boolean enabled;
private volatile boolean wakeRequested;
private volatile int maxZoom = 6;
private volatile long playerIntervalMs = 2000;
private volatile List<Player> players = List.of();
private volatile java.util.function.Supplier<String> overlay;
private long configAt;
private long coarseUploadAt;
private boolean stateDirty;
private boolean warnedMissing;
private String lastOverlaySent;
private long overlaySentAt;
private String lastPlayersSent = "";
private long playersSentAt;
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
this.client = client;
this.worldRoot = worldRoot;
this.cacheDir = cacheDir;
this.log = log;
this.store = new TileStore(cacheDir.resolve("tiles"));
thread = new Thread(this::run, "scopenet-map");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
}
public void start() {
loadState();
thread.start();
live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) {
players = List.copyOf(next);
}
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
public void setOverlay(java.util.function.Supplier<String> source) {
overlay = source;
}
/** The world was saved: look for changed regions soon. */
public void requestScan() {
synchronized (wake) {
wakeRequested = true;
wake.notifyAll();
}
}
@Override public void close() {
closed = true;
live.shutdownNow();
thread.interrupt();
saveState();
}
// ---- main loop ----
private void run() {
while (!closed) {
long pause = 10_000;
try {
if (!checkConfig()) pause = 60_000;
else {
boolean worked = cycle();
pause = worked ? 150 : 10_000;
}
} catch (InterruptedException e) {
return;
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
pause = 30_000;
} catch (Exception e) {
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
pause = 60_000;
}
if (stateDirty) saveState();
try {
synchronized (wake) {
if (!wakeRequested) wake.wait(pause);
wakeRequested = false;
}
} catch (InterruptedException e) {
return;
}
}
}
private boolean checkConfig() throws IOException, InterruptedException {
long now = System.currentTimeMillis();
if (now < configAt) return enabled;
configAt = now + 60_000;
try {
JsonObject config = client.get("map/config");
boolean on = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt()));
if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong());
long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0;
if (on && epoch != state.epoch) {
if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again.");
store.clear();
state = new State();
state.epoch = epoch;
renderedAt.clear();
stateDirty = true;
}
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
enabled = on;
} catch (PanelClient.HttpFailure e) {
enabled = false; // an older panel without the map
}
return enabled;
}
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
private boolean cycle() throws IOException, InterruptedException {
if (!Files.isDirectory(worldRoot)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
}
return false;
}
long now = System.currentTimeMillis();
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
List<Stale> stale = new ArrayList<>();
List<Player> online = players;
for (Dim dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
for (Path file : stream) {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
continue;
}
long mtime = Files.getLastModifiedTime(file).toMillis();
String key = dim.dir() + "/" + rx + "." + rz;
Long done = state.regions.get(key);
if (done != null && mtime <= done) continue;
Long last = renderedAt.get(key);
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
if (Files.size(file) < 8192) continue;
double distance = Double.MAX_VALUE;
for (Player p : online) {
if (!dim.id().equals(p.dimension())) continue;
double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256);
distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
}
stale.add(new Stale(dim, file, key, mtime, rx, rz, distance));
}
}
}
boolean worked = false;
if (!stale.isEmpty()) {
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
Stale next = stale.get(0);
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
worked = true;
}
if (flushUploads(stale.size() <= 1)) worked = true;
return worked;
}
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
int[][] tiles;
try {
tiles = RegionRenderer.render(file, dim.kind());
} catch (IOException | RuntimeException e) {
// A region being written right now; try again after the gap.
renderedAt.put(key, System.currentTimeMillis());
return;
}
renderedAt.put(key, System.currentTimeMillis());
List<TileStore.Key> changed = new ArrayList<>();
if (tiles != null) {
for (int i = 0; i < 4; i++) {
TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1));
if (store.update(k, tiles[i])) {
changed.add(k);
state.dirty.add(k.toString());
}
}
}
// Rebuild the zoom pyramid above whatever changed.
List<TileStore.Key> level = changed;
for (int z = 0; z < maxZoom && !level.isEmpty(); z++) {
Set<Long> seen = new HashSet<>();
List<TileStore.Key> next = new ArrayList<>();
for (TileStore.Key child : level) {
long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL);
if (!seen.add(id)) continue;
TileStore.Key parent = store.rebuildParent(child);
if (parent != null) {
next.add(parent);
state.dirty.add(parent.toString());
}
}
level = next;
}
state.regions.put(key, mtime);
stateDirty = true;
}
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException {
if (state.dirty.isEmpty()) return false;
long now = System.currentTimeMillis();
boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS;
List<TileStore.Key> ready = new ArrayList<>();
for (String s : state.dirty) {
TileStore.Key k = TileStore.Key.parse(s);
if (k.zoom() == 0 || coarseNow) ready.add(k);
}
if (ready.isEmpty()) return false;
ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed()));
if (coarseNow) coarseUploadAt = now;
boolean sentAny = false;
int batches = 0;
int i = 0;
while (i < ready.size() && batches < 8 && !closed) {
String dimension = ready.get(i).dim();
ByteArrayOutputStream body = new ByteArrayOutputStream();
List<TileStore.Key> inBatch = new ArrayList<>();
while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) {
TileStore.Key k = ready.get(i++);
int[] pixels = store.load(k);
if (pixels == null) {
state.dirty.remove(k.toString());
continue;
}
byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE);
body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array());
body.writeBytes(png);
inBatch.add(k);
}
if (inBatch.isEmpty()) continue;
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
stateDirty = true;
sentAny = true;
batches++;
}
return sentAny;
}
private static String idOf(String dir) {
for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id();
return "minecraft:" + dir;
}
// ---- live data ----
private void sendLive() {
if (closed || !enabled) return;
try {
long now = System.currentTimeMillis();
List<Player> snapshot = players;
JsonArray list = new JsonArray();
for (Player p : snapshot) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", Math.round(p.x() * 10) / 10.0);
o.addProperty("y", Math.round(p.y() * 10) / 10.0);
o.addProperty("z", Math.round(p.z() * 10) / 10.0);
o.addProperty("yaw", Math.round(p.yaw()));
list.add(o);
}
String text = list.toString();
if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) {
JsonObject body = new JsonObject();
body.add("players", list);
client.post("map/players", body);
lastPlayersSent = text;
playersSentAt = now;
}
java.util.function.Supplier<String> source = overlay;
String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get();
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
lastOverlaySent = ov;
overlaySentAt = now;
}
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
} catch (Exception ignored) {
// the next tick retries
}
}
// ---- world layout ----
private Path regionDir(Dim dim) {
String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString();
Path parent = worldRoot.toAbsolutePath().getParent();
List<Path> candidates = switch (dim.dir()) {
case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
worldRoot.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
worldRoot.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
// ---- state ----
private void loadState() {
try {
Path f = cacheDir.resolve("state.json");
if (Files.isRegularFile(f)) {
State s = gson.fromJson(Files.readString(f), State.class);
if (s != null && s.regions != null && s.dirty != null) state = s;
}
} catch (IOException | RuntimeException e) {
state = new State();
}
}
private void saveState() {
try {
Files.createDirectories(cacheDir);
Path f = cacheDir.resolve("state.json");
Path tmp = f.resolveSibling("state.json.tmp");
Files.writeString(tmp, gson.toJson(state));
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
stateDirty = false;
} catch (IOException e) {
// the cache is only an optimisation
}
}
}
@@ -0,0 +1,418 @@
stone,989898
granite,B6775E
polished_granite,BC7A63
diorite,E5E5E5
polished_diorite,EAEBED
andesite,A6A6A6
polished_andesite,A1A3A2
grass_block,618149
dirt,A37048
coarse_dirt,91633F
podzol,6F4913
cobblestone,9B9B9B
oak_planks,C69A53
spruce_planks,8B6231
birch_planks,EAD389
jungle_planks,C38656
acacia_planks,CD622C
cherry_planks,FFC2BB
dark_oak_planks,513112
mangrove_planks,8F3930
bamboo_planks,EBD051
bamboo_mosaic,E8CC4F
bedrock,686868
sand,FFEFB6
red_sand,E87011
gravel,A09A99
gold_ore,B1A17C
deepslate_gold_ore,8C7B5A
iron_ore,A69C93
deepslate_iron_ore,817871
coal_ore,808080
deepslate_coal_ore,5A5A5D
nether_gold_ore,8C3929
oak_log,B88F4E
spruce_log,845E2F
birch_log,EBD89C
jungle_log,B67F4A
acacia_log,B76235
cherry_log,E2A6A0
dark_oak_log,523414
mangrove_log,7C332A
mangrove_roots,5A4629
muddy_mangrove_roots,554533
bamboo_block,A9AC40
stripped_spruce_log,805E2F
stripped_birch_log,E9CE83
stripped_jungle_log,C98F57
stripped_acacia_log,CB642D
stripped_cherry_log,FFB5AC
stripped_dark_oak_log,4F3315
stripped_oak_log,C39952
stripped_mangrove_log,852B2B
stripped_bamboo_block,D9BE48
oak_leaves,4D7515
spruce_leaves,355C35
jungle_leaves,557E14
cherry_leaves,FFB8D3
dark_oak_leaves,517B16
mangrove_leaves,456913
azalea_leaves,6A8B2B
flowering_azalea_leaves,778742
sponge,EEEA49
wet_sponge,CFDD45
lapis_ore,7E8BAC
deepslate_lapis_ore,5B6A8C
lapis_block,1447AB
dispenser,959393
sandstone,FFF3BB
chiseled_sandstone,FFECAE
cut_sandstone,FFF1B3
note_block,6B422A
powered_rail,A78151
detector_rail,967C69
piston,BB975E
white_wool,FBFFFF
orange_wool,FF7200
magenta_wool,E741D9
light_blue_wool,2EC8FF
yellow_wool,FFC40E
lime_wool,7EE207
pink_wool,FF8BB1
gray_wool,4A5357
light_gray_wool,ADADA2
cyan_wool,07A6B1
purple_wool,8C20D2
blue_wool,3137C0
brown_wool,8B5026
green_wool,638515
red_wool,C31E17
black_wool,18191E
gold_block,FFD328
iron_block,FFFFFF
bricks,B76F5B
tnt,AD4034
bookshelf,8F6F3F
chiseled_bookshelf,D9AB5E
mossy_cobblestone,85906F
obsidian,110A1D
spawner,28364C
diamond_ore,91ACAB
deepslate_diamond_ore,628181
diamond_block,57FFF4
crafting_table,915228
farmland,AE764B
furnace,868585
oak_door,AB8246
iron_door,EDEBEB
redstone_ore,AB8080
deepslate_redstone_ore,7F5556
snow,F9FFFF
ice,85B0FF
cactus,629B28
clay,C0C9DA
jukebox,714A33
netherrack,762626
soul_sand,634938
soul_soil,5C4334
basalt,616269
polished_basalt,79777A
glowstone,D19A5A
nether_portal,6400EA
carved_pumpkin,B75D01
jack_o_lantern,FFAC27
cake,FFE1D8
repeater,C3BFBE
oak_trapdoor,97753A
spruce_trapdoor,7E5D31
birch_trapdoor,FDEBB8
jungle_trapdoor,B98053
acacia_trapdoor,BE602F
cherry_trapdoor,FFC2BB
dark_oak_trapdoor,5C3814
mangrove_trapdoor,862F29
bamboo_trapdoor,F2D857
stone_bricks,959395
mossy_stone_bricks,8B947E
cracked_stone_bricks,908F90
chiseled_stone_bricks,919091
packed_mud,AD7C57
mud_bricks,A77958
brown_mushroom_block,B68259
red_mushroom_block,F42220
mushroom_stem,F8EEDF
pumpkin,F28404
melon,83B014
mycelium,87767A
nether_bricks,36161D
enchanting_table,9C5461
cauldron,59585A
end_portal_frame,6B9273
end_stone,FBFFAD
dragon_egg,0E0A12
redstone_lamp,743C1B
emerald_ore,80A689
deepslate_emerald_ore,5B7F68
emerald_block,1CF859
command_block,D89C78
beacon,79FFF9
anvil,585858
chipped_anvil,585858
damaged_anvil,585858
comparator,CBC4C1
daylight_detector,9F8B6D
redstone_block,D61800
nether_quartz_ore,8F4844
hopper,5C5A5C
quartz_block,FFF8EF
chiseled_quartz_block,FFF9EE
quartz_pillar,FFF8EF
activator_rail,8C6654
dropper,959393
white_terracotta,FFD5BD
orange_terracotta,C45A1B
magenta_terracotta,B6627E
light_blue_terracotta,8680A7
yellow_terracotta,E39B15
lime_terracotta,7C8F36
pink_terracotta,C45353
gray_terracotta,463127
light_gray_terracotta,A57D71
cyan_terracotta,686F6F
purple_terracotta,904E64
blue_terracotta,58436F
brown_terracotta,5E3A25
green_terracotta,5C652D
red_terracotta,AE3E2A
black_terracotta,2D1910
slime_block,7CEA60
iron_trapdoor,F6F6F6
prismarine,6EC6AE
prismarine_bricks,6ED1BF
dark_prismarine,386F59
sea_lantern,CEF4E6
hay_block,C9A700
terracotta,B96A45
coal_block,141212
packed_ice,82AFFF
red_sandstone,DD6A10
chiseled_red_sandstone,DF680A
cut_red_sandstone,E76E0F
smooth_stone_slab,CCCCCC
smooth_stone,C1C1C1
bamboo_fence_gate,FBDF57
bamboo_fence,FBE05B
spruce_door,815E32
birch_door,FFF1C6
jungle_door,C78B5B
acacia_door,CC693A
cherry_door,FFBBB3
dark_oak_door,5D3B17
mangrove_door,89312E
bamboo_door,E9CE53
chorus_plant,714071
chorus_flower,B88EB8
purpur_block,CE92CE
purpur_pillar,D196D1
end_stone_bricks,F7FFB0
dirt_path,B59043
repeating_command_block,967DCF
chain_command_block,9BC4B1
nether_wart_block,8B0000
red_nether_bricks,540003
bone_block,FFFBD6
observer,787878
shulker_box,AA6FAA
white_shulker_box,F7FEFF
orange_shulker_box,FF6F00
magenta_shulker_box,D330C5
light_blue_shulker_box,23BDFF
yellow_shulker_box,FFBA03
lime_shulker_box,6ED206
pink_shulker_box,FF78A4
gray_shulker_box,42464C
light_gray_shulker_box,97978B
cyan_shulker_box,0892A5
purple_shulker_box,7615BE
blue_shulker_box,2629AB
brown_shulker_box,814B20
green_shulker_box,5D7A1C
red_shulker_box,AB1614
black_shulker_box,1E1E23
white_glazed_terracotta,DFFFF2
orange_glazed_terracotta,BCB266
magenta_glazed_terracotta,FE6AE7
light_blue_glazed_terracotta,62C2FE
yellow_glazed_terracotta,FFCC4D
lime_glazed_terracotta,C1F02E
pink_glazed_terracotta,FF9DBD
gray_glazed_terracotta,646D71
light_gray_glazed_terracotta,ACCACC
cyan_glazed_terracotta,358F98
purple_glazed_terracotta,7F2AB9
blue_glazed_terracotta,2C43AA
brown_glazed_terracotta,917F63
green_glazed_terracotta,8BAD47
red_glazed_terracotta,DD362E
black_glazed_terracotta,521F22
white_concrete,F6FEFF
orange_concrete,FF6E00
magenta_concrete,CE29C1
light_blue_concrete,139EF2
yellow_concrete,FFB300
lime_concrete,68CD08
pink_concrete,FF69A0
gray_concrete,41454A
light_gray_concrete,98988B
cyan_concrete,098FA6
purple_concrete,7214BE
blue_concrete,272AAE
brown_concrete,75431C
green_concrete,566F24
red_concrete,AD1717
black_concrete,090B12
white_concrete_powder,FCFFFF
orange_concrete_powder,FF8608
magenta_concrete_powder,EA55DF
light_blue_concrete_powder,45D3FF
yellow_concrete_powder,FFD624
lime_concrete_powder,8FE71C
pink_concrete_powder,FFA1C4
gray_concrete_powder,5C6266
light_gray_concrete_powder,BCBCB4
cyan_concrete_powder,1AB2C0
purple_concrete_powder,9931D8
blue_concrete_powder,474BCB
brown_concrete_powder,986036
green_concrete_powder,73912B
red_concrete_powder,CD312C
black_concrete_powder,1E1F26
dead_tube_coral_block,9F9590
dead_brain_coral_block,978E8A
dead_bubble_coral_block,A0958F
dead_fire_coral_block,A0958F
dead_horn_coral_block,A29993
tube_coral_block,2559FB
brain_coral_block,FD5EBB
bubble_coral_block,C90BC5
fire_coral_block,C71827
horn_coral_block,FFE939
sea_pickle,6D7627
blue_ice,649EFF
bamboo,EBD051
scaffolding,CF9A4B
loom,AD8E68
barrel,A3753C
smoker,686562
blast_furnace,616163
cartography_table,7E684C
fletching_table,F0D999
lectern,D3A258
smithing_table,444555
stonecutter,968F86
warped_stem,388586
stripped_warped_stem,349C97
warped_nylium,2A8B79
warped_wart_block,0C9194
crimson_stem,89334F
stripped_crimson_stem,943B5E
crimson_nylium,9F1717
shroomlight,FF8F35
crimson_planks,7B3351
warped_planks,2C7F78
crimson_trapdoor,7E3553
warped_trapdoor,2F9186
crimson_door,8B395B
warped_door,2A9A92
structure_block,6B586E
jigsaw,615263
target,FFB8A8
bee_nest,F6BD48
beehive,C0954F
honey_block,FFB41D
honeycomb_block,FF9A06
netherite_block,514A4C
ancient_debris,734C41
crying_obsidian,230549
respawn_anchor,520BB6
lodestone,B3B5B9
blackstone,332B32
polished_blackstone,403944
polished_blackstone_bricks,3A323C
cracked_polished_blackstone_bricks,362C34
chiseled_polished_blackstone,403944
gilded_blackstone,43312C
chiseled_nether_bricks,39191F
cracked_nether_bricks,31151A
quartz_bricks,FFF9EF
amethyst_block,9A69E9
budding_amethyst,9968E3
tuff,84857B
polished_tuff,757F78
chiseled_tuff,71786E
tuff_bricks,777C73
chiseled_tuff_bricks,868A80
calcite,FEFFFA
powder_snow,F9FFFF
sculk_sensor,005466
calibrated_sculk_sensor,165D7A
sculk,0D2028
sculk_vein,013A47
sculk_catalyst,0F252E
copper_block,EA7650
exposed_copper,C49375
weathered_copper,7DBB80
oxidized_copper,58C69D
copper_ore,979892
deepslate_copper_ore,70716C
oxidized_cut_copper,56BB96
weathered_cut_copper,80B17D
exposed_cut_copper,BC8F73
cut_copper,E97551
oxidized_chiseled_copper,5AC49D
weathered_chiseled_copper,78B782
exposed_chiseled_copper,BC8C72
chiseled_copper,E06E49
copper_door,EA7954
exposed_copper_door,C89079
oxidized_copper_door,59C39D
weathered_copper_door,80B77F
copper_trapdoor,E87550
exposed_copper_trapdoor,C49377
oxidized_copper_trapdoor,5AC49D
weathered_copper_trapdoor,7DBB80
copper_grate,E97650
exposed_copper_grate,C49377
weathered_copper_grate,7BB980
oxidized_copper_grate,58C49B
copper_bulb,BE5F37
exposed_copper_bulb,A57E66
weathered_copper_bulb,6B9A75
oxidized_copper_bulb,4CA182
dripstone_block,A37F6A
azalea,78972E
flowering_azalea,879446
moss_block,6A852E
big_dripleaf,83AC31
rooted_dirt,B07853
mud,494549
deepslate,6A6A6D
cobbled_deepslate,5E5E62
polished_deepslate,585859
deepslate_tiles,424243
deepslate_bricks,555557
chiseled_deepslate,424242
cracked_deepslate_bricks,4E4E4F
cracked_deepslate_tiles,3F3F3F
smooth_basalt,57575F
raw_iron_block,CBA07A
raw_copper_block,BC7955
raw_gold_block,FFBC1D
ochre_froglight,FFF9CD
verdant_froglight,EDFFEC
pearlescent_froglight,FFF9F8
reinforced_deepslate,61645F
crafter,897677
trial_spawner,2D455D
vault,3E5360
heavy_core,626873
1 stone 989898
2 granite B6775E
3 polished_granite BC7A63
4 diorite E5E5E5
5 polished_diorite EAEBED
6 andesite A6A6A6
7 polished_andesite A1A3A2
8 grass_block 618149
9 dirt A37048
10 coarse_dirt 91633F
11 podzol 6F4913
12 cobblestone 9B9B9B
13 oak_planks C69A53
14 spruce_planks 8B6231
15 birch_planks EAD389
16 jungle_planks C38656
17 acacia_planks CD622C
18 cherry_planks FFC2BB
19 dark_oak_planks 513112
20 mangrove_planks 8F3930
21 bamboo_planks EBD051
22 bamboo_mosaic E8CC4F
23 bedrock 686868
24 sand FFEFB6
25 red_sand E87011
26 gravel A09A99
27 gold_ore B1A17C
28 deepslate_gold_ore 8C7B5A
29 iron_ore A69C93
30 deepslate_iron_ore 817871
31 coal_ore 808080
32 deepslate_coal_ore 5A5A5D
33 nether_gold_ore 8C3929
34 oak_log B88F4E
35 spruce_log 845E2F
36 birch_log EBD89C
37 jungle_log B67F4A
38 acacia_log B76235
39 cherry_log E2A6A0
40 dark_oak_log 523414
41 mangrove_log 7C332A
42 mangrove_roots 5A4629
43 muddy_mangrove_roots 554533
44 bamboo_block A9AC40
45 stripped_spruce_log 805E2F
46 stripped_birch_log E9CE83
47 stripped_jungle_log C98F57
48 stripped_acacia_log CB642D
49 stripped_cherry_log FFB5AC
50 stripped_dark_oak_log 4F3315
51 stripped_oak_log C39952
52 stripped_mangrove_log 852B2B
53 stripped_bamboo_block D9BE48
54 oak_leaves 4D7515
55 spruce_leaves 355C35
56 jungle_leaves 557E14
57 cherry_leaves FFB8D3
58 dark_oak_leaves 517B16
59 mangrove_leaves 456913
60 azalea_leaves 6A8B2B
61 flowering_azalea_leaves 778742
62 sponge EEEA49
63 wet_sponge CFDD45
64 lapis_ore 7E8BAC
65 deepslate_lapis_ore 5B6A8C
66 lapis_block 1447AB
67 dispenser 959393
68 sandstone FFF3BB
69 chiseled_sandstone FFECAE
70 cut_sandstone FFF1B3
71 note_block 6B422A
72 powered_rail A78151
73 detector_rail 967C69
74 piston BB975E
75 white_wool FBFFFF
76 orange_wool FF7200
77 magenta_wool E741D9
78 light_blue_wool 2EC8FF
79 yellow_wool FFC40E
80 lime_wool 7EE207
81 pink_wool FF8BB1
82 gray_wool 4A5357
83 light_gray_wool ADADA2
84 cyan_wool 07A6B1
85 purple_wool 8C20D2
86 blue_wool 3137C0
87 brown_wool 8B5026
88 green_wool 638515
89 red_wool C31E17
90 black_wool 18191E
91 gold_block FFD328
92 iron_block FFFFFF
93 bricks B76F5B
94 tnt AD4034
95 bookshelf 8F6F3F
96 chiseled_bookshelf D9AB5E
97 mossy_cobblestone 85906F
98 obsidian 110A1D
99 spawner 28364C
100 diamond_ore 91ACAB
101 deepslate_diamond_ore 628181
102 diamond_block 57FFF4
103 crafting_table 915228
104 farmland AE764B
105 furnace 868585
106 oak_door AB8246
107 iron_door EDEBEB
108 redstone_ore AB8080
109 deepslate_redstone_ore 7F5556
110 snow F9FFFF
111 ice 85B0FF
112 cactus 629B28
113 clay C0C9DA
114 jukebox 714A33
115 netherrack 762626
116 soul_sand 634938
117 soul_soil 5C4334
118 basalt 616269
119 polished_basalt 79777A
120 glowstone D19A5A
121 nether_portal 6400EA
122 carved_pumpkin B75D01
123 jack_o_lantern FFAC27
124 cake FFE1D8
125 repeater C3BFBE
126 oak_trapdoor 97753A
127 spruce_trapdoor 7E5D31
128 birch_trapdoor FDEBB8
129 jungle_trapdoor B98053
130 acacia_trapdoor BE602F
131 cherry_trapdoor FFC2BB
132 dark_oak_trapdoor 5C3814
133 mangrove_trapdoor 862F29
134 bamboo_trapdoor F2D857
135 stone_bricks 959395
136 mossy_stone_bricks 8B947E
137 cracked_stone_bricks 908F90
138 chiseled_stone_bricks 919091
139 packed_mud AD7C57
140 mud_bricks A77958
141 brown_mushroom_block B68259
142 red_mushroom_block F42220
143 mushroom_stem F8EEDF
144 pumpkin F28404
145 melon 83B014
146 mycelium 87767A
147 nether_bricks 36161D
148 enchanting_table 9C5461
149 cauldron 59585A
150 end_portal_frame 6B9273
151 end_stone FBFFAD
152 dragon_egg 0E0A12
153 redstone_lamp 743C1B
154 emerald_ore 80A689
155 deepslate_emerald_ore 5B7F68
156 emerald_block 1CF859
157 command_block D89C78
158 beacon 79FFF9
159 anvil 585858
160 chipped_anvil 585858
161 damaged_anvil 585858
162 comparator CBC4C1
163 daylight_detector 9F8B6D
164 redstone_block D61800
165 nether_quartz_ore 8F4844
166 hopper 5C5A5C
167 quartz_block FFF8EF
168 chiseled_quartz_block FFF9EE
169 quartz_pillar FFF8EF
170 activator_rail 8C6654
171 dropper 959393
172 white_terracotta FFD5BD
173 orange_terracotta C45A1B
174 magenta_terracotta B6627E
175 light_blue_terracotta 8680A7
176 yellow_terracotta E39B15
177 lime_terracotta 7C8F36
178 pink_terracotta C45353
179 gray_terracotta 463127
180 light_gray_terracotta A57D71
181 cyan_terracotta 686F6F
182 purple_terracotta 904E64
183 blue_terracotta 58436F
184 brown_terracotta 5E3A25
185 green_terracotta 5C652D
186 red_terracotta AE3E2A
187 black_terracotta 2D1910
188 slime_block 7CEA60
189 iron_trapdoor F6F6F6
190 prismarine 6EC6AE
191 prismarine_bricks 6ED1BF
192 dark_prismarine 386F59
193 sea_lantern CEF4E6
194 hay_block C9A700
195 terracotta B96A45
196 coal_block 141212
197 packed_ice 82AFFF
198 red_sandstone DD6A10
199 chiseled_red_sandstone DF680A
200 cut_red_sandstone E76E0F
201 smooth_stone_slab CCCCCC
202 smooth_stone C1C1C1
203 bamboo_fence_gate FBDF57
204 bamboo_fence FBE05B
205 spruce_door 815E32
206 birch_door FFF1C6
207 jungle_door C78B5B
208 acacia_door CC693A
209 cherry_door FFBBB3
210 dark_oak_door 5D3B17
211 mangrove_door 89312E
212 bamboo_door E9CE53
213 chorus_plant 714071
214 chorus_flower B88EB8
215 purpur_block CE92CE
216 purpur_pillar D196D1
217 end_stone_bricks F7FFB0
218 dirt_path B59043
219 repeating_command_block 967DCF
220 chain_command_block 9BC4B1
221 nether_wart_block 8B0000
222 red_nether_bricks 540003
223 bone_block FFFBD6
224 observer 787878
225 shulker_box AA6FAA
226 white_shulker_box F7FEFF
227 orange_shulker_box FF6F00
228 magenta_shulker_box D330C5
229 light_blue_shulker_box 23BDFF
230 yellow_shulker_box FFBA03
231 lime_shulker_box 6ED206
232 pink_shulker_box FF78A4
233 gray_shulker_box 42464C
234 light_gray_shulker_box 97978B
235 cyan_shulker_box 0892A5
236 purple_shulker_box 7615BE
237 blue_shulker_box 2629AB
238 brown_shulker_box 814B20
239 green_shulker_box 5D7A1C
240 red_shulker_box AB1614
241 black_shulker_box 1E1E23
242 white_glazed_terracotta DFFFF2
243 orange_glazed_terracotta BCB266
244 magenta_glazed_terracotta FE6AE7
245 light_blue_glazed_terracotta 62C2FE
246 yellow_glazed_terracotta FFCC4D
247 lime_glazed_terracotta C1F02E
248 pink_glazed_terracotta FF9DBD
249 gray_glazed_terracotta 646D71
250 light_gray_glazed_terracotta ACCACC
251 cyan_glazed_terracotta 358F98
252 purple_glazed_terracotta 7F2AB9
253 blue_glazed_terracotta 2C43AA
254 brown_glazed_terracotta 917F63
255 green_glazed_terracotta 8BAD47
256 red_glazed_terracotta DD362E
257 black_glazed_terracotta 521F22
258 white_concrete F6FEFF
259 orange_concrete FF6E00
260 magenta_concrete CE29C1
261 light_blue_concrete 139EF2
262 yellow_concrete FFB300
263 lime_concrete 68CD08
264 pink_concrete FF69A0
265 gray_concrete 41454A
266 light_gray_concrete 98988B
267 cyan_concrete 098FA6
268 purple_concrete 7214BE
269 blue_concrete 272AAE
270 brown_concrete 75431C
271 green_concrete 566F24
272 red_concrete AD1717
273 black_concrete 090B12
274 white_concrete_powder FCFFFF
275 orange_concrete_powder FF8608
276 magenta_concrete_powder EA55DF
277 light_blue_concrete_powder 45D3FF
278 yellow_concrete_powder FFD624
279 lime_concrete_powder 8FE71C
280 pink_concrete_powder FFA1C4
281 gray_concrete_powder 5C6266
282 light_gray_concrete_powder BCBCB4
283 cyan_concrete_powder 1AB2C0
284 purple_concrete_powder 9931D8
285 blue_concrete_powder 474BCB
286 brown_concrete_powder 986036
287 green_concrete_powder 73912B
288 red_concrete_powder CD312C
289 black_concrete_powder 1E1F26
290 dead_tube_coral_block 9F9590
291 dead_brain_coral_block 978E8A
292 dead_bubble_coral_block A0958F
293 dead_fire_coral_block A0958F
294 dead_horn_coral_block A29993
295 tube_coral_block 2559FB
296 brain_coral_block FD5EBB
297 bubble_coral_block C90BC5
298 fire_coral_block C71827
299 horn_coral_block FFE939
300 sea_pickle 6D7627
301 blue_ice 649EFF
302 bamboo EBD051
303 scaffolding CF9A4B
304 loom AD8E68
305 barrel A3753C
306 smoker 686562
307 blast_furnace 616163
308 cartography_table 7E684C
309 fletching_table F0D999
310 lectern D3A258
311 smithing_table 444555
312 stonecutter 968F86
313 warped_stem 388586
314 stripped_warped_stem 349C97
315 warped_nylium 2A8B79
316 warped_wart_block 0C9194
317 crimson_stem 89334F
318 stripped_crimson_stem 943B5E
319 crimson_nylium 9F1717
320 shroomlight FF8F35
321 crimson_planks 7B3351
322 warped_planks 2C7F78
323 crimson_trapdoor 7E3553
324 warped_trapdoor 2F9186
325 crimson_door 8B395B
326 warped_door 2A9A92
327 structure_block 6B586E
328 jigsaw 615263
329 target FFB8A8
330 bee_nest F6BD48
331 beehive C0954F
332 honey_block FFB41D
333 honeycomb_block FF9A06
334 netherite_block 514A4C
335 ancient_debris 734C41
336 crying_obsidian 230549
337 respawn_anchor 520BB6
338 lodestone B3B5B9
339 blackstone 332B32
340 polished_blackstone 403944
341 polished_blackstone_bricks 3A323C
342 cracked_polished_blackstone_bricks 362C34
343 chiseled_polished_blackstone 403944
344 gilded_blackstone 43312C
345 chiseled_nether_bricks 39191F
346 cracked_nether_bricks 31151A
347 quartz_bricks FFF9EF
348 amethyst_block 9A69E9
349 budding_amethyst 9968E3
350 tuff 84857B
351 polished_tuff 757F78
352 chiseled_tuff 71786E
353 tuff_bricks 777C73
354 chiseled_tuff_bricks 868A80
355 calcite FEFFFA
356 powder_snow F9FFFF
357 sculk_sensor 005466
358 calibrated_sculk_sensor 165D7A
359 sculk 0D2028
360 sculk_vein 013A47
361 sculk_catalyst 0F252E
362 copper_block EA7650
363 exposed_copper C49375
364 weathered_copper 7DBB80
365 oxidized_copper 58C69D
366 copper_ore 979892
367 deepslate_copper_ore 70716C
368 oxidized_cut_copper 56BB96
369 weathered_cut_copper 80B17D
370 exposed_cut_copper BC8F73
371 cut_copper E97551
372 oxidized_chiseled_copper 5AC49D
373 weathered_chiseled_copper 78B782
374 exposed_chiseled_copper BC8C72
375 chiseled_copper E06E49
376 copper_door EA7954
377 exposed_copper_door C89079
378 oxidized_copper_door 59C39D
379 weathered_copper_door 80B77F
380 copper_trapdoor E87550
381 exposed_copper_trapdoor C49377
382 oxidized_copper_trapdoor 5AC49D
383 weathered_copper_trapdoor 7DBB80
384 copper_grate E97650
385 exposed_copper_grate C49377
386 weathered_copper_grate 7BB980
387 oxidized_copper_grate 58C49B
388 copper_bulb BE5F37
389 exposed_copper_bulb A57E66
390 weathered_copper_bulb 6B9A75
391 oxidized_copper_bulb 4CA182
392 dripstone_block A37F6A
393 azalea 78972E
394 flowering_azalea 879446
395 moss_block 6A852E
396 big_dripleaf 83AC31
397 rooted_dirt B07853
398 mud 494549
399 deepslate 6A6A6D
400 cobbled_deepslate 5E5E62
401 polished_deepslate 585859
402 deepslate_tiles 424243
403 deepslate_bricks 555557
404 chiseled_deepslate 424242
405 cracked_deepslate_bricks 4E4E4F
406 cracked_deepslate_tiles 3F3F3F
407 smooth_basalt 57575F
408 raw_iron_block CBA07A
409 raw_copper_block BC7955
410 raw_gold_block FFBC1D
411 ochre_froglight FFF9CD
412 verdant_froglight EDFFEC
413 pearlescent_froglight FFF9F8
414 reinforced_deepslate 61645F
415 crafter 897677
416 trial_spawner 2D455D
417 vault 3E5360
418 heavy_core 626873
@@ -22,7 +22,7 @@ class EssentialsServiceTest {
public Pos pos() { return pos; }
public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return true; }
public boolean hasPermission(String n) { return !n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass"); }
public long playtimeSeconds() { return 0; }
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
public void clearHeld() { }
@@ -17,6 +17,8 @@ final class Kit {
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>();
final Set<String> denied = new HashSet<>();
/** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */
final Set<String> granted = new HashSet<>();
Item held;
final List<Item> inventory = new ArrayList<>();
long playtime = 3700;
@@ -26,7 +28,7 @@ final class Kit {
public Pos pos() { return pos; }
public void send(String m) { inbox.add(Format.plain(m)); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return !denied.contains(n); }
public boolean hasPermission(String n) { return granted.contains(n) || (!n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass") && !denied.contains(n)); }
public long playtimeSeconds() { return playtime; }
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
public void clearHeld() { held = null; }
@@ -0,0 +1,76 @@
package net.scopenet.core;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class LimitsTest {
@Test void permissionTiersRaiseALimit() {
assertEquals(5, Limits.tier(n -> false, "scopenet.homes.", 5, 500));
assertEquals(10, Limits.tier(Set.of("scopenet.homes.10")::contains, "scopenet.homes.", 5, 500));
assertEquals(25, Limits.tier(Set.of("scopenet.homes.10", "scopenet.homes.25", "scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "highest wins");
assertEquals(5, Limits.tier(Set.of("scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "a lower tier never reduces the base");
assertEquals(Integer.MAX_VALUE, Limits.tier(Set.of("scopenet.homes.unlimited")::contains, "scopenet.homes.", 5, 500));
assertEquals("unlimited", Limits.show(Integer.MAX_VALUE));
}
@Test void homesFollowConfigAndPermissions(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(2, 2500, 60_000, 15, 0, 1, 8, Map.of());
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, kit.env.rng);
svc.setHome(steve, new String[]{"a"});
svc.setHome(steve, new String[]{"b"});
svc.setHome(steve, new String[]{"c"});
assertTrue(steve.last().contains("limit of 2"));
steve.granted.add("scopenet.homes.4");
svc.setHome(steve, new String[]{"c"});
svc.setHome(steve, new String[]{"d"});
svc.setHome(steve, new String[]{"e"});
assertTrue(steve.last().contains("limit of 4"), steve.last());
svc.setHome(steve, new String[]{"waytoolongname"});
assertTrue(steve.last().contains("at most 8"), steve.last());
svc.setWarp(steve, "spawn");
svc.setWarp(steve, "pvp");
assertTrue(steve.last().contains("already has 1 warps"), steve.last());
}
@Test void teleportCommandsCoolDownOnlyAfterTeleporting(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(5, 2500, 60_000, 15, 0, 0, 32, Map.of("home", 30, "warp", 10));
CommandSet set = new CommandSet(kit.env, cfg);
set.essentials.setHome(steve, new String[]{"base"});
set.run("home", steve, "nope"); // unknown home: nothing happened, no cooldown
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), steve.last());
set.run("home", steve, "base");
assertTrue(steve.last().contains("Please wait 30s"), steve.last());
kit.now.addAndGet(31_000);
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), steve.last());
set.run("warp", steve, "missing");
set.run("warp", steve, "missing");
assertFalse(steve.last().contains("Please wait"), "a failed warp costs nothing");
steve.granted.add("scopenet.cooldown.bypass");
set.run("home", steve, "base");
assertTrue(steve.last().contains("Teleported to home"), "bypass skips cooldowns");
}
@Test void rtpKeepsAwayFromTheCentre(@TempDir Path dir) {
Kit kit = new Kit(dir);
Kit.Player steve = kit.platform.add("Steve");
EssentialsConfig cfg = new EssentialsConfig(5, 400, 60_000, 50, 300, 0, 32, Map.of());
EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, new java.util.Random(7));
for (int i = 0; i < 20; i++) {
svc.rtp(steve);
int x = (int) steve.pos.x(), z = (int) steve.pos.z();
assertTrue(Math.max(Math.abs(x), Math.abs(z)) >= 300, "landed at " + x + "," + z);
}
}
}
@@ -0,0 +1,29 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.core.map.*;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MapOverlayTest {
@Test void overlayCarriesShapesAndPlainText() {
var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16)));
var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron <Wolves>", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8,
"<div class=\"scopenet-detail\"><strong>Iron &lt;Wolves&gt;</strong> <span>[IRN]</span><br>1 claimed chunk in this area</div>");
var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6,
"<div class=\"scopenet-detail\"><strong>Warp</strong> a<br><code>/warp a</code></div>");
JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of()));
JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject();
assertEquals("#3366cc", c.get("color").getAsString());
assertEquals(4, c.getAsJsonArray("outer").size());
assertEquals("Iron <Wolves> [IRN]", c.getAsJsonArray("lines").get(0).getAsString());
assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString());
JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject();
assertEquals("warp", p.get("kind").getAsString());
assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString());
assertFalse(json.toString().contains("<div"), "no markup reaches the viewer");
}
}
@@ -0,0 +1,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();
}
}
-2
View File
@@ -16,7 +16,6 @@ dependencies {
implementation project(':common')
if (features) {
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
implementation project(':bluemap')
// Optional at runtime: present only when the LuckPerms mod is installed.
compileOnly 'net.luckperms:api:5.4'
}
@@ -32,6 +31,5 @@ processResources {
jar {
archiveBaseName = "scopenet-fabric-${mc}"
from project(':common').sourceSets.main.output
if (features) from project(':bluemap').sourceSets.main.output
}
base.archivesName = "scopenet-fabric-${mc}"
@@ -7,5 +7,5 @@
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" }
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
}
@@ -1,28 +0,0 @@
package net.scopenet.fabric.features;
import net.scopenet.bluemap.MapAddon;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapService;
import java.util.logging.Logger;
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
final class BlueMapHook {
private BlueMapHook() {}
/**
* Registered while the mod initialises (BlueMap reads its script list when it starts), so the data comes from
* {@code service}, which only exists once the server is running: until then the map is simply empty.
*/
static Object start(java.util.function.Supplier<MapService> service, Logger log) {
return MapAddon.start(new MapAddon.Feed() {
@Override public MapData snapshot() { MapService s = service.get(); return s == null ? MapData.empty() : s.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() {
MapService s = service.get();
return s == null ? net.scopenet.core.map.MapModel.playersJson("", java.util.List.of(), System.currentTimeMillis()) : s.playersJson();
}
}, log);
}
static void stop(Object handle) { ((MapAddon) handle).stop(); }
}
@@ -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"); }
}
@@ -55,18 +55,14 @@ public final class FabricFeatures implements Runnable {
private Perms perms;
private FabricConfig config;
private ActionPoller poller;
private net.scopenet.core.RewardPoller rewards;
private MapService map;
private Object blueMap;
private int ticks;
private String placeholderStatus = "off";
CommandSet commandSet() { return commands; }
@Override public void run() {
// Hook into BlueMap early: it reads the list of web scripts when it starts.
if (FabricConfig.load().bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) {
try { blueMap = BlueMapHook.start(() -> map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); }
}
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
@@ -116,10 +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 BlueMap addon (claims, pins, player panel) when BlueMap is installed.
// 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("bluemap.show_homes", false), s.getMotd());
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)));
@@ -130,7 +128,6 @@ public final class FabricFeatures implements Runnable {
private void stopping() {
ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false;
if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; }
if (platform != null) platform.shutdown();
commands = null;
map = null;
@@ -155,6 +152,7 @@ public final class FabricFeatures implements Runnable {
cache.tick(p -> link.pushState(p));
link.tick();
poller.tick();
rewards.tick();
map.tick();
}
if (ticks % 40 == 0) commands.tick();
@@ -72,4 +72,26 @@ final class FabricPlatform implements Platform {
@Override public void runMain(Runnable task) { server.execute(task); }
@Override public void runAsync(Runnable task) { io.execute(task); }
@Override public boolean giveItem(UUID id, String item, int amount) {
ServerPlayer p = server.getPlayerList().getPlayer(id);
ResourceLocation location = ResourceLocation.tryParse(item);
if (p == null || location == null) return false;
var found = net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(location);
if (found.isEmpty() || found.get() == net.minecraft.world.item.Items.AIR) return false;
net.minecraft.world.item.Item type = found.get();
for (int left = amount; left > 0; ) {
int n = Math.min(left, type.getMaxStackSize());
net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(type, n);
p.getInventory().add(stack);
if (!stack.isEmpty()) p.drop(stack, false);
left -= n;
}
return true;
}
@Override public boolean console(String command) {
server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command);
return true;
}
}
@@ -16,8 +16,11 @@ final class Perms {
static boolean luckPermsPresent() { return LUCKPERMS; }
/** Nodes that grant extras (limit tiers like scopenet.homes.10): nobody has them unless a permissions system says so. */
static boolean isGrantNode(String node) { return node.startsWith("scopenet.homes.") || node.startsWith("scopenet.limit."); }
static boolean isAdminNode(String node) {
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
return node.equals("scopenet.claims.bypass") || node.equals("scopenet.cooldown.bypass") || node.startsWith("scopenet.admin")
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
}
@@ -30,6 +33,7 @@ final class Perms {
// LuckPerms not ready yet or an API change: fall back to the defaults below.
}
}
if (isGrantNode(node)) return false;
if (isAdminNode(node)) return player.hasPermissions(2);
return everyoneByDefault || player.hasPermissions(2);
}
@@ -33,7 +33,7 @@ public final class Bridge {
}));
integration.startClaimSync();
Path config = Path.of("config/scopenet.properties");
if (Settings.liveMapEnabled(config)) integration.enableLiveMap();
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);
@@ -71,13 +71,13 @@ public final class Bridge {
current.tick();
if (++mapTicks >= 20) {
mapTicks = 0;
List<LiveMapSync.Player> positions = new ArrayList<>();
List<net.scopenet.worldmap.WorldMapSync.Player> positions = new ArrayList<>();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0,
Math.round(player.getYRot()), Math.round(player.getXRot())));
}
current.liveMapPlayers(positions);
current.mapPlayers(positions);
}
}
+1 -3
View File
@@ -6,7 +6,6 @@ repositories {
dependencies {
implementation project(':common')
implementation project(':api')
implementation project(':bluemap')
implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
// Optional integrations: the plugin detects these at runtime and works without them.
@@ -23,7 +22,6 @@ jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output
from project(':bluemap').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
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'
}
@@ -30,8 +30,8 @@ final class ClientLinkBridge implements PluginMessageListener {
private final Integration integration;
private final ShopPoints shops;
private ActionPoller poller;
private net.scopenet.core.RewardPoller rewards;
private MapService mapService;
private Object blueMap;
private EconomyHandler economy;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
@@ -64,7 +64,7 @@ final class ClientLinkBridge implements PluginMessageListener {
}
/**
* Shop points, actions from the map, and the BlueMap addon. Called once the command handlers exist; any of them may be
* 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) {
@@ -81,11 +81,14 @@ final class ClientLinkBridge implements PluginMessageListener {
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
plugin.getConfig().getBoolean("bluemap.show-homes", false), Bukkit.getServer().getName());
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20);
if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) {
blueMap = BlueMapHook.start(mapService, plugin.getLogger());
}
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). */
@@ -113,20 +116,7 @@ final class ClientLinkBridge implements PluginMessageListener {
}
}
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
private static final class BlueMapHook {
static Object start(MapService service, Logger log) {
return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() {
@Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
}, log);
}
static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); }
}
void stop() {
if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; }
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
}
@@ -166,6 +156,22 @@ final class ClientLinkBridge implements PluginMessageListener {
}
@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(); }
@@ -51,10 +51,19 @@ 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();
// Live positions center the in-app BlueMap claim view on the player.
if (getConfig().getBoolean("livemap.enabled", true)) {
integration.enableLiveMap();
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,
@@ -114,7 +123,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
bindCommand("autoclaim", guilds);
}
// Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it.
// Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws.
clientLink.startMap(essentialsHandler, guildHandler, economy);
// Reloading an auth plugin cannot silently admit existing sessions.
@@ -160,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()) {
@@ -169,11 +178,11 @@ 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);
integration.mapPlayers(list);
}
public static String dimension(World world) {
@@ -133,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)) {
@@ -143,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);
}
}
@@ -158,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);
@@ -178,6 +185,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
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;
}
@@ -263,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;
}
@@ -324,13 +355,13 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
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.");
@@ -366,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) {
@@ -471,6 +505,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
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);
@@ -46,15 +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 live player positions to the panel for the launcher BlueMap claim view.
# Configure BlueMap's public address and enable live positions 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,15 +117,21 @@ integrations:
report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# 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}.
# ==============================================================================
bluemap:
rewards:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# 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
@@ -4,7 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap]
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
commands:
scopenet:
+1 -1
View File
@@ -6,7 +6,7 @@ pluginManagement {
}
}
rootProject.name = 'scopenet-integrations'
include 'common', 'api', 'paper', 'bluemap'
include 'common', 'api', 'paper'
// Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
+36 -78
View File
@@ -635,90 +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_address"].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 BlueMap settings and 2D tiles. Only paths under the
/// BlueMap address supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.clone();
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 BlueMap site".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)
}
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());
/// 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 get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
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());
}
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);
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));
}
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);
Ok(info)
}
/// 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 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("BlueMap asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 {
return Err("BlueMap asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
@@ -735,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?;
+3 -2
View File
@@ -97,10 +97,11 @@ pub fn run() {
commands::get_player_stats,
commands::get_leaderboard,
commands::get_public_servers,
commands::fetch_bluemap_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,
-4
View File
@@ -4,7 +4,6 @@ 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`).
@@ -41,8 +40,6 @@ pub struct AppState {
pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>,
/// BlueMap addresses received from the panel, keyed by linked game server.
pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Vec<RunningGame>>,
@@ -61,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,
+14 -1
View File
@@ -40,10 +40,17 @@ html, body {
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */
overflow-wrap: anywhere;
}
#app { height: 100%; }
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
*, *::before, *::after { min-width: 0; }
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
@@ -85,8 +92,11 @@ button {
font-weight: 520;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
white-space: nowrap;
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
white-space: normal;
max-width: 100%;
}
button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; }
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
@@ -154,3 +164,6 @@ label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } }
code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; }
pre { white-space: pre-wrap; }
@@ -1,80 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
import { openUrl } from '../lib/tauri';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
let frame = $state<HTMLIFrameElement>();
let loaded = $state(false);
let selected = $state<{ uuid: string; name: string } | null>(null);
let nonce = $state(0);
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
function onMessage(e: MessageEvent) {
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
const m = e.data;
if (!m || m.source !== 'scopenet-map') return;
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
else if (m.type === 'deselect') selected = null;
}
onMount(() => {
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
});
const reload = () => { loaded = false; selected = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={loaded}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<iframe
bind:this={frame}
title="{serverName} live map"
src={address}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
allow="fullscreen; clipboard-write"
referrerpolicy="no-referrer"
onload={() => (loaded = true)}
></iframe>
{/key}
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
{#if selected}
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
{/if}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
</style>
+11 -10
View File
@@ -2,15 +2,15 @@
import { onMount } from 'svelte';
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import BlueMapTiles from './BlueMapTiles.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;
} = $props();
type GameServer = { id: number; name: string; instance_id: string; map_address?: 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);
@@ -32,9 +32,10 @@
async function refreshPosition(id: number) {
try {
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
if (serverId !== id) return;
player = info.me ?? null;
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');
@@ -134,20 +135,20 @@
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
</div>
</div>
{#if selectedServer?.map_address}
{#key `${serverId}:${selectedServer.map_address}`}
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{#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="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</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}
<div class="footer">
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
{#if painting}<span>Updating claims…</span>{/if}
{#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
</div>
</div>
@@ -1,113 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke, inTauri } from '../lib/tauri';
import { invoke } from '../lib/tauri';
import type { GuildClaim } from '../lib/types';
type Resource = { status: number; data: string | null };
type Site = { maps?: string[]; mapDataRoot?: string };
type MapSettings = { lowres?: { tileSize?: [number, number]; lodFactor?: number; lodCount?: number } };
type Point = { x: number; z: number };
const cache = new Map<string, string>();
const pending = new Set<string>();
let { address, serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
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 mapRoot = $state('');
let mapId = $state('');
let tileBlocks = $state(32);
let images = $state<Record<string, string>>({});
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>>({});
async function resource(url: string): Promise<Resource> {
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
const response = await fetch(url);
if (!response.ok) return { status: response.status, data: null };
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return { status: response.status, data: btoa(binary) };
}
async function json<T>(url: string): Promise<T> {
const response = await resource(url);
if (response.status !== 200 || !response.data) throw new Error(`BlueMap returned ${response.status} for settings`);
return JSON.parse(atob(response.data)) as T;
}
function tilePath(x: number, z: number): string {
const part = (n: number) => (n < 0 ? '-' : '') + Math.abs(n).toString().split('').join('/');
return `x${part(x)}/z${part(z)}.png`;
}
function chooseMap(ids: string[]): string {
const slug = dimension.replace('minecraft:', '');
if (slug === 'overworld') return ids.find((id) => !/nether|the_end|(^|[_-])end($|[_-])/i.test(id)) ?? '';
return ids.find((id) => slug === 'the_nether' ? /nether/i.test(id) : /(^|[_-])end($|[_-])/i.test(id)) ?? '';
}
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 url = address, dim = dimension;
let alive = true;
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
(async () => {
const base = new URL(url.endsWith('/') ? url : `${url}/`);
const site = await json<Site>(new URL('settings.json', base).href);
const id = chooseMap(site.maps ?? []);
if (!id) throw new Error(`BlueMap has no ${dim.replace('minecraft:', '')} map`);
const root = new URL(`${(site.mapDataRoot || 'maps').replace(/\/$/, '')}/${encodeURIComponent(id)}/`, base).href;
const settings = await json<MapSettings>(new URL('settings.json', root).href);
if (!alive) return;
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
mapRoot = root; mapId = id; loading = false;
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
return () => { alive = false; };
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 (!mapRoot) return [] as { key: string; url: string; left: number; top: number; size: number }[];
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
const out = [];
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
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++) {
const url = new URL(`tiles/1/${tilePath(x, z)}`, mapRoot).href;
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
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;
});
$effect(() => {
const tiles = visibleTiles;
for (const tile of tiles) {
if (images[tile.key] || pending.has(tile.key)) continue;
if (cache.has(tile.key)) { images = { ...images, [tile.key]: cache.get(tile.key)! }; continue; }
pending.add(tile.key);
resource(tile.url).then((r) => {
if (r.status !== 200 || !r.data) return;
const src = `data:image/png;base64,${r.data}`;
cache.set(tile.key, src);
if (cache.size > 512) cache.delete(cache.keys().next().value!);
images = { ...images, [tile.key]: src };
}).catch(() => {}).finally(() => pending.delete(tile.key));
}
});
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
@@ -133,7 +94,7 @@
}
function down(e: PointerEvent) {
if (!mapRoot) return;
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';
@@ -170,11 +131,11 @@
});
</script>
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="BlueMap territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
<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)}
{#if images[tile.key]}<img class="tile" src={images[tile.key]} alt="" draggable="false" style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />{/if}
<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>
@@ -191,10 +152,10 @@
{/each}
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
{#if loading}<div class="message">Loading BlueMap tiles…</div>{/if}
{#if error}<div class="message error">{error}</div>{/if}
{#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">{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}</div>
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
</div>
<style>
@@ -208,7 +169,6 @@
.preview.remove { background: #fb718590; }
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
.error { color: #fda4af; }
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
.help { left: 10px; }.coords { right: 10px; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from './PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
@@ -213,7 +214,7 @@
</span>
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
<div class="who">
<strong>{m?.name ?? 'Former member'}</strong>
<strong>{#if m}<PlayerLink uuid={tx.actor_uuid} name={m.name} />{:else}Former member{/if}</strong>
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
</div>
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, RefreshCw } from '@lucide/svelte';
import MapViewer from '@scopenet/map/MapViewer.svelte';
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
import { invoke } from '../lib/tauri';
import { activeAccount, app } from '../lib/store.svelte';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, instanceId, onclose }: { serverId: number; serverName: string; instanceId: string; onclose: () => void } = $props();
let info = $state<MapInfo | null>(null);
let overlay = $state<MapOverlay | null>(null);
let error = $state('');
let nonce = $state(0);
const panel = $derived(activeAccount()?.panel_url ?? app.panelUrl);
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
const avatarUrl = (uuid: string) => (panel ? `${panel}/api/v1/avatar/${uuid}?size=64&v=${app.skinVersion}` : '');
// The feed reloads the overlay every couple of seconds and renews the tile key before it expires.
onMount(() => startFeed({
loadInfo: () => invoke<MapInfo>('get_map_info', { serverId }),
loadOverlay: () => invoke<MapOverlay>('get_map_overlay', { serverId }),
onInfo: (i) => { info = i; error = ''; },
onOverlay: (o) => { overlay = o; error = ''; },
onError: (m) => { error = m; }
}));
const reload = () => { info = null; overlay = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={!!info?.ready}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map{#if overlay} · {overlay.players.length} online{/if}</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error} selfUuid={activeAccount()?.uuid ?? null}>
{#snippet player(p: LivePlayer, close: () => void)}
<MapPlayerPanel {serverId} {instanceId} uuid={p.uuid} nameHint={p.name} onclose={close} />
{/snippet}
</MapViewer>
{/key}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; --map-accent: var(--accent); }
</style>
@@ -2,6 +2,7 @@
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';
@@ -84,7 +85,7 @@
<div class="head">
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
<div class="who">
<h3>{name}</h3>
<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>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { app } from '../lib/store.svelte';
// A player's name that opens their profile. Safe to place inside other clickable rows.
let { uuid, name = '', class: cls = '', children }: { uuid?: string | null; name?: string; class?: string; children?: Snippet } = $props();
const open = (e: Event) => { e.stopPropagation(); if (uuid) app.viewProfileUuid = uuid; };
</script>
{#if uuid}
<button type="button" class="plink {cls}" title="View {name || 'player'}'s profile" onclick={open}
onkeydown={(e) => e.stopPropagation()}>{#if children}{@render children()}{:else}{name}{/if}</button>
{:else}
<span class={cls}>{#if children}{@render children()}{:else}{name}{/if}</span>
{/if}
<style>
.plink { all: unset; cursor: pointer; font: inherit; color: inherit; border-radius: 4px; transition: color 0.12s ease; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: inherit; vertical-align: bottom; }
.plink:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.plink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
+1 -1
View File
@@ -86,7 +86,7 @@
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); }
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.65rem; bottom: 0.65rem; width: 3.5px; border-radius: 0 3px 3px 0; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
.tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
.inst:hover .tip { opacity: 1; }
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
.big { width: 2.6rem; height: 2.6rem; position: relative; border-radius: var(--radius-sm); transition: transform 0.12s; }
+1 -1
View File
@@ -67,7 +67,7 @@
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
.name { gap: 0.5rem; }
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name strong { min-width: 0; flex: 0 1 auto; font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
.state.online { color: var(--success); }
+23 -4
View File
@@ -133,14 +133,31 @@ const wallet = {
};
const mockWallet = () => JSON.parse(JSON.stringify(wallet));
// `?stress` in the dev URL makes every name, title and description much longer, to check that no screen lets text spill out of its box.
const STRESS = typeof location !== 'undefined' && new URLSearchParams(location.search).has('stress');
const TEXT_KEYS = /(^|_)(name|title|username|description|motd|message|desc|label|bio|tagline|text|rank|guild)(_|$)/i;
function stress(value: unknown, key = ''): unknown {
if (Array.isArray(value)) return value.map((v) => stress(v, key));
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stress(v, k)]));
if (typeof value === 'string' && TEXT_KEYS.test(key) && /^[A-Za-z][A-Za-z '&:,.\-]{2,60}$/.test(value)) return `${value} With An Extraordinarily Long Unbroken Continuation`;
return value;
}
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
const result = await mockInvokeInner(cmd, args);
return STRESS ? stress(result) : result;
}
async function mockInvokeInner(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_livemap_info':
return { enabled: true, ready: true, message: '', me: null, players: 0,
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
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' },
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Shield, Map, Users, MessageSquare, Plus, Crown, UserCheck, Trash2,
@@ -83,7 +84,7 @@
}
}
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string };
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string; inviter_uuid?: string };
let invites = $state<Invite[]>([]);
async function loadInvites() {
try { invites = await invoke<Invite[]>('my_guild_invites'); } catch { invites = []; }
@@ -259,7 +260,7 @@
{#each invites as inv (inv.id)}
<div class="invite">
<span class="dot"><Shield size={15} /></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by {inv.inviter}</span></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by <PlayerLink uuid={inv.inviter_uuid} name={inv.inviter} /></span></span>
<button class="sm ghost" onclick={() => answerInvite(inv, false)}>Decline</button>
<button class="sm primary" onclick={() => answerInvite(inv, true)} disabled={!!myGuild} title={myGuild ? 'Leave your guild first' : ''}>Accept</button>
</div>
@@ -450,7 +451,7 @@
<div class="author-meta">
<Avatar uuid={p.author_uuid} name={p.author_name} size={1.8} />
<div class="author-info">
<span class="author-name">{p.author_name}</span>
<span class="author-name"><PlayerLink uuid={p.author_uuid} name={p.author_name} /></span>
<span class="post-date tiny muted">{new Date(p.created_at).toLocaleString()}</span>
</div>
</div>
+7 -7
View File
@@ -4,7 +4,7 @@
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte';
import MapModal from '../components/MapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
import { invoke, openUrl } from '../lib/tauri';
@@ -13,8 +13,8 @@
const b = $derived(app.manifest!.branding);
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
// A game server linked to this instance with BlueMap configured.
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
// A game server linked to this instance that draws the SCOPENET Map.
let mapServer = $state<{ id: number; name: string } | null>(null);
let mapOpen = $state(false);
$effect(() => {
const id = inst?.id;
@@ -22,8 +22,8 @@
mapOpen = false;
if (!id) return;
let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; })
.catch(() => {});
return () => { alive = false; };
});
@@ -57,7 +57,7 @@
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
@@ -82,7 +82,7 @@
</aside>
{#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
{#if mapOpen && mapServer}<MapModal serverId={mapServer.id} serverName={mapServer.name} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
</div>
<style>
+9
View File
@@ -27,7 +27,10 @@
exploration: Compass
};
let extras = $state<Record<string, string[]>>({});
async function loadData() {
invoke<Record<string, string[]>>('get_reward_summaries').then((r) => (extras = r ?? {})).catch(() => {});
loading = true;
try {
const [lvl, quests, achs] = await Promise.all([
@@ -227,6 +230,7 @@
<p class="ach-desc">{ach.description}</p>
<div class="ach-footer">
<span class="xp-tag">+{ach.xp_reward} XP</span>
{#each extras[`achievement:${ach.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
{#if ach.unlocked}
<span class="badge unlocked-tag"><Check size={11} /> Unlocked</span>
{:else}
@@ -293,6 +297,7 @@
<strong>{currentCount}</strong> / {targetCount} ({pct}%)
</span>
<span class="xp-badge">+{q.xp_reward} XP</span>
{#each extras[`quest:${q.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
</div>
<div class="progress-bar-bg">
<div class="progress-fill" style:width="{pct}%"></div>
@@ -626,6 +631,9 @@
}
.progress-labels {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
justify-content: space-between;
font-size: 0.8rem;
}
@@ -754,6 +762,7 @@
align-items: center;
margin-top: 0.2rem;
}
.extra-tag { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-size: 0.7rem; font-weight: 600; color: #fbbf24; background: color-mix(in srgb, #fbbf24 14%, transparent); border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent); }
.xp-tag {
font-size: 0.78rem;
font-weight: 700;
+7 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
@@ -301,7 +302,7 @@
<div class="card-left" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = req.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = req.uuid)}>
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="user-title"><PlayerLink uuid={req.uuid} name={req.username} /></span>
<span class="tiny muted">Wants to be friends</span>
</div>
</div>
@@ -326,7 +327,7 @@
<div class="card-left">
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="user-title"><PlayerLink uuid={req.uuid} name={req.username} /></span>
<span class="tiny muted">Request pending...</span>
</div>
</div>
@@ -366,7 +367,7 @@
<span class="status-dot" class:online={friend.online}></span>
</div>
<div class="name-block">
<span class="user-title">{friend.username}</span>
<span class="user-title"><PlayerLink uuid={friend.uuid} name={friend.username} /></span>
{#if friend.online && friend.playing_on}
<span class="tiny playing-on">Playing on {friend.playing_on}</span>
{:else if friend.online}
@@ -508,7 +509,7 @@
<span class="status-dot" class:online={friend.online}></span>
</div>
<div class="conv-meta">
<span class="conv-name">{friend.username}</span>
<span class="conv-name"><PlayerLink uuid={friend.uuid} name={friend.username} /></span>
{#if friend.playing_on}
<span class="tiny muted">{friend.playing_on}</span>
{/if}
@@ -531,7 +532,7 @@
>
<Avatar uuid={activeChatFriend.uuid} name={activeChatFriend.username} size={2.2} />
<div>
<strong>{activeChatFriend.username}</strong>
<strong><PlayerLink uuid={activeChatFriend.uuid} name={activeChatFriend.username} /></strong>
<span class="tiny muted block">{activeChatFriend.online ? 'Active now' : 'Offline'}</span>
</div>
</div>
@@ -599,7 +600,7 @@
<div class="invite-top">
<Avatar uuid={inv.sender_uuid} name={inv.sender_name} size={2.5} />
<div class="invite-meta">
<span class="inviter-name"><strong>{inv.sender_name}</strong> invited you to play</span>
<span class="inviter-name"><strong><PlayerLink uuid={inv.sender_uuid} name={inv.sender_name} /></strong> invited you to play</span>
<span class="server-title">{inv.server_name}</span>
<span class="tiny muted server-addr">{inv.instance_name}</span>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import {
Trophy, Swords, Skull, Pickaxe, Clock, Box, MessageSquare, LogIn,
RefreshCw, LoaderCircle, Users, Server, Flame, Sparkles, ChevronDown, Check,
@@ -472,7 +473,7 @@
#{row.rank}
</span>
<Avatar uuid={row.uuid} name={row.username} size={2.2} />
<strong class="baltop-name">{row.username}</strong>
<strong class="baltop-name"><PlayerLink uuid={row.uuid} name={row.username} /></strong>
{#if isMe}<span class="badge you">You</span>{/if}
</div>
<span class="baltop-val highlight">
@@ -610,7 +611,7 @@
</span>
<div class="col-player row align-center gap-sm">
<Avatar name={row.name} uuid={row.uuid} size={1.8} />
<strong class="row-player-name">{row.name}</strong>
<strong class="row-player-name"><PlayerLink uuid={row.uuid} name={row.name} /></strong>
{#if isMe}<span class="badge you">You</span>{/if}
</div>
<strong class="col-main highlight">{statValue(row, sortField)}</strong>
+4 -2
View File
@@ -9,7 +9,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
"types": ["svelte", "vite/client"],
"baseUrl": ".",
"paths": { "svelte": ["./node_modules/svelte"], "svelte/*": ["./node_modules/svelte/*"], "@scopenet/map": ["../shared/map/index.ts"], "@scopenet/map/*": ["../shared/map/*"] }
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*.ts", "src/**/*.svelte", "../shared/map/**/*.ts", "../shared/map/**/*.svelte"]
}
+4 -1
View File
@@ -1,11 +1,14 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { fileURLToPath } from 'node:url';
// https://tauri.app/start/frontend/vite/
export default defineConfig({
plugins: [svelte()],
// The map viewer is shared with the admin panel (repo root: shared/map).
resolve: { dedupe: ['svelte'], alias: { '@scopenet/map': fileURLToPath(new URL('../shared/map', import.meta.url)) } },
clearScreen: false,
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } },
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] }, fs: { allow: ['..'] } },
envPrefix: ['VITE_', 'TAURI_ENV_'],
build: {
target: 'es2022',
+55
View File
@@ -719,6 +719,61 @@ const MIGRATIONS: &[&str] = &[
);
CREATE INDEX guild_join_requests_player ON guild_join_requests(uuid);
"#,
// Rewards beyond XP and titles: what quests, achievements and rank milestones hand out (money, items, permissions,
// claim chunks, commands…), the queue of earned bundles, and the deliveries waiting for a game server.
r#"
CREATE TABLE reward_bundles (
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
actions TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (source_type, source_id)
);
CREATE TABLE reward_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE reward_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL,
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
sent_at TEXT,
delivered_at TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX reward_deliveries_pending ON reward_deliveries(delivered_at, server_id);
CREATE TABLE player_bonuses (
uuid TEXT PRIMARY KEY,
claim_chunks INTEGER NOT NULL DEFAULT 0
);
CREATE TRIGGER reward_on_achievement AFTER INSERT ON user_achievements
WHEN EXISTS (SELECT 1 FROM reward_bundles WHERE source_type = 'achievement' AND source_id = NEW.achievement_id) BEGIN
INSERT INTO reward_queue (uuid, source_type, source_id, created_at)
VALUES (NEW.user_uuid, 'achievement', NEW.achievement_id, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
END;
"#,
// Emails sent from the panel: players can opt out of non-essential mail, and every send is logged.
r#"
ALTER TABLE users ADD COLUMN email_optout INTEGER NOT NULL DEFAULT 0;
CREATE TABLE email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
audience TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
sent INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+641
View File
@@ -0,0 +1,641 @@
//! Discord messages the panel posts: announcements (achievements, new players, new guilds) built from admin-edited templates,
//! and live embeds (server status, active guilds, leaderboards, richest players) that are edited in place on a schedule.
//!
//! Everything goes through a webhook, so no bot is needed. Templates use `{placeholders}`; unknown ones are left as typed.
use crate::error::{AppError, AppResult};
use crate::routes::discord;
use crate::state::AppState;
use crate::store;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::time::Duration;
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug, Default)]
#[serde(default)]
pub struct Field {
pub name: String,
pub value: String,
pub inline: bool,
}
/// Everything about how one Discord message looks. All text may contain placeholders.
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
#[serde(default)]
pub struct EmbedStyle {
pub enabled: bool,
/// Webhook display name and picture (empty = SCOPENET's).
pub username: String,
pub avatar_url: String,
/// Plain text above the embed. Mentions work here, e.g. `<@&roleid>`.
pub content: String,
pub title: String,
pub url: String,
pub description: String,
/// `#rrggbb`
pub color: String,
pub thumbnail: String,
pub image: String,
pub author_name: String,
pub author_icon: String,
pub footer: String,
pub footer_icon: String,
/// Show the time the message was made.
pub timestamp: bool,
pub fields: Vec<Field>,
}
impl Default for EmbedStyle {
fn default() -> Self {
Self {
enabled: true, username: String::new(), avatar_url: String::new(), content: String::new(), title: String::new(), url: String::new(),
description: String::new(), color: "#8b6cff".into(), thumbnail: String::new(), image: String::new(), author_name: String::new(),
author_icon: String::new(), footer: String::new(), footer_icon: String::new(), timestamp: true, fields: vec![],
}
}
}
fn field(name: &str, value: &str, inline: bool) -> Field {
Field { name: name.into(), value: value.into(), inline }
}
/// What the three announcements say until an admin changes them.
pub fn default_templates() -> BTreeMap<String, EmbedStyle> {
let mut m = BTreeMap::new();
m.insert(
"achievement".to_string(),
EmbedStyle {
title: "🏆 Achievement unlocked".into(),
description: "**{player}** earned **{achievement}**\n*{achievement_description}*".into(),
color: "#fcd34d".into(),
thumbnail: "{avatar}".into(),
footer: "{category} · {server_name}".into(),
fields: vec![field("Reward", "+{xp} XP", true), field("Level", "{level}", true), field("Guild", "{guild_line}", true)],
..Default::default()
},
);
m.insert(
"member".to_string(),
EmbedStyle {
enabled: false,
title: "👋 New player".into(),
description: "Welcome **{player}**! You are player number **{members_total}**.".into(),
color: "#6ee7b7".into(),
thumbnail: "{avatar}".into(),
footer: "{server_name}".into(),
..Default::default()
},
);
m.insert(
"guild".to_string(),
EmbedStyle {
title: "🛡️ New guild".into(),
description: "**{guild}** [{tag}] was founded by **{leader}**.\n{guild_description}".into(),
color: "#22d3ee".into(),
thumbnail: "{leader_avatar}".into(),
footer: "{guilds_total} guilds in total · {server_name}".into(),
fields: vec![field("Leader", "{leader}", true), field("Members", "{members}", true), field("Land", "{claims} chunks", true)],
..Default::default()
},
);
m
}
pub async fn templates(state: &AppState) -> AppResult<BTreeMap<String, EmbedStyle>> {
let mut stored: BTreeMap<String, EmbedStyle> = store::kv_get(state, "discord_templates").await?;
for (k, v) in default_templates() {
stored.entry(k).or_insert(v);
}
Ok(stored)
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
pub type Vars = BTreeMap<String, String>;
/// Replace `{name}` with its value. Unknown placeholders stay as typed so mistakes are visible.
pub fn fill(text: &str, vars: &Vars) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
match after.find('}') {
Some(end) if !after[..end].contains(char::is_whitespace) && !after[..end].contains('{') => {
match vars.get(&after[..end]) {
Some(v) => out.push_str(v),
None => {
out.push('{');
out.push_str(&after[..end]);
out.push('}');
}
}
rest = &after[end + 1..];
}
_ => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
fn cut(s: &str, max: usize) -> String {
if s.chars().count() <= max { s.to_string() } else { format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>()) }
}
fn color(s: &str) -> u32 {
u32::from_str_radix(s.trim().trim_start_matches('#'), 16).ok().filter(|c| *c <= 0xFFFFFF).unwrap_or(0x8b6cff)
}
fn https(s: String) -> Option<String> {
let s = s.trim().to_string();
(s.starts_with("https://") || s.starts_with("http://")).then_some(s)
}
/// The webhook body for a style and its values, kept within Discord's limits.
pub fn render(style: &EmbedStyle, vars: &Vars, extra_description: Option<&str>) -> Value {
let mut e = Map::new();
let title = fill(&style.title, vars);
if !title.trim().is_empty() {
e.insert("title".into(), json!(cut(&title, 256)));
}
if let Some(u) = https(fill(&style.url, vars)) {
e.insert("url".into(), json!(u));
}
let mut desc = fill(&style.description, vars);
if let Some(extra) = extra_description {
desc = desc.replace("{rows}", extra);
}
if !desc.trim().is_empty() {
e.insert("description".into(), json!(cut(&desc, 4096)));
}
e.insert("color".into(), json!(color(&style.color)));
if let Some(u) = https(fill(&style.thumbnail, vars)) {
e.insert("thumbnail".into(), json!({ "url": u }));
}
if let Some(u) = https(fill(&style.image, vars)) {
e.insert("image".into(), json!({ "url": u }));
}
let author = fill(&style.author_name, vars);
if !author.trim().is_empty() {
let mut a = Map::new();
a.insert("name".into(), json!(cut(&author, 256)));
if let Some(u) = https(fill(&style.author_icon, vars)) {
a.insert("icon_url".into(), json!(u));
}
e.insert("author".into(), Value::Object(a));
}
let footer = fill(&style.footer, vars);
if !footer.trim().is_empty() {
let mut f = Map::new();
f.insert("text".into(), json!(cut(&footer, 2048)));
if let Some(u) = https(fill(&style.footer_icon, vars)) {
f.insert("icon_url".into(), json!(u));
}
e.insert("footer".into(), Value::Object(f));
}
if style.timestamp {
e.insert("timestamp".into(), json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)));
}
let fields: Vec<Value> = style
.fields
.iter()
.map(|f| (fill(&f.name, vars), fill(&f.value, vars), f.inline))
.filter(|(n, v, _)| !n.trim().is_empty() && !v.trim().is_empty())
.take(25)
.map(|(n, v, i)| json!({ "name": cut(&n, 256), "value": cut(&v, 1024), "inline": i }))
.collect();
if !fields.is_empty() {
e.insert("fields".into(), json!(fields));
}
let mut body = Map::new();
body.insert("username".into(), json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80)));
if let Some(u) = https(fill(&style.avatar_url, vars)) {
body.insert("avatar_url".into(), json!(u));
}
let content = fill(&style.content, vars);
if !content.trim().is_empty() {
body.insert("content".into(), json!(cut(&content, 2000)));
}
// Mentions only where the admin wrote them in the message text; never from player-supplied names.
body.insert("allowed_mentions".into(), json!({ "parse": ["roles", "users"] }));
body.insert("embeds".into(), json!([Value::Object(e)]));
Value::Object(body)
}
/// Player-supplied text can't ping anyone or break formatting out of its place.
pub fn plain(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect::<String>().replace('@', "@\u{200b}").replace('`', "'").chars().take(120).collect()
}
fn base_url(state: &AppState) -> String {
state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string()
}
pub fn avatar(state: &AppState, uuid: &str) -> String {
let base = base_url(state);
if base.is_empty() || uuid.is_empty() { String::new() } else { format!("{base}/api/v1/avatar/{uuid}?size=128") }
}
async fn server_name(state: &AppState) -> String {
store::branding(state).await.map(|b| b.name).unwrap_or_default()
}
/// Values common to every message.
async fn common(state: &AppState) -> Vars {
let mut v = Vars::new();
v.insert("server_name".into(), server_name(state).await);
v.insert("panel".into(), base_url(state));
v.insert("time".into(), format!("<t:{}:f>", chrono::Utc::now().timestamp()));
v.insert("updated".into(), format!("<t:{}:R>", chrono::Utc::now().timestamp()));
v
}
pub const ANNOUNCEMENT_PLACEHOLDERS: [(&str, &[&str]); 3] = [
("achievement", &["player", "uuid", "avatar", "achievement", "achievement_description", "xp", "category", "level", "rank_title", "guild", "guild_tag", "guild_line", "server_name", "panel", "time"]),
("member", &["player", "uuid", "avatar", "members_total", "server_name", "panel", "time"]),
("guild", &["guild", "tag", "leader", "leader_avatar", "guild_description", "members", "claims", "guilds_total", "server_name", "panel", "time"]),
];
pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars {
let mut v = common(state).await;
v.insert("player".into(), plain(name));
v.insert("uuid".into(), uuid.to_string());
v.insert("avatar".into(), avatar(state, uuid));
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
let (l, t) = level.unwrap_or((1, None));
v.insert("level".into(), l.to_string());
v.insert("rank_title".into(), plain(&t.unwrap_or_default()));
let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
let (gn, gt) = guild.unwrap_or_default();
v.insert("guild".into(), plain(&gn));
v.insert("guild_tag".into(), plain(&gt));
v.insert("guild_line".into(), if gn.is_empty() { "No guild".into() } else { format!("[{}] {}", plain(&gt), plain(&gn)) });
v
}
pub async fn guild_vars(state: &AppState, guild_id: &str) -> Vars {
let mut v = common(state).await;
let row: Option<(String, String, String, String, i64, i64)> = sqlx::query_as(
"SELECT g.name, g.tag, g.description, g.leader_uuid, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id) FROM guilds g WHERE g.id = ?",
)
.bind(guild_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let (name, tag, desc, leader_uuid, members, claims) = row.unwrap_or_default();
let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&leader_uuid).fetch_optional(&state.db).await.ok().flatten().unwrap_or_default();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guilds").fetch_one(&state.db).await.unwrap_or(0);
v.insert("guild".into(), plain(&name));
v.insert("tag".into(), plain(&tag));
v.insert("guild_description".into(), plain(&desc));
v.insert("leader".into(), plain(&leader));
v.insert("leader_avatar".into(), avatar(state, &leader_uuid));
v.insert("members".into(), members.to_string());
v.insert("claims".into(), claims.to_string());
v.insert("guilds_total".into(), total.to_string());
v
}
/// Example values, for previews and test messages.
pub async fn sample_vars(state: &AppState, kind: &str) -> Vars {
let mut v = common(state).await;
let me = state.cfg.admin_username.clone();
let pairs: Vec<(&str, String)> = match kind {
"achievement" => vec![("player", me), ("uuid", "00000000-0000-0000-0000-000000000000".into()), ("avatar", String::new()), ("achievement", "Deep Diver".into()),
("achievement_description", "Mine 5,000 blocks below Y 0".into()), ("xp", "250".into()), ("category", "Mining".into()), ("level", "14".into()),
("rank_title", "Veteran".into()), ("guild", "Iron Wolves".into()), ("guild_tag", "IRON".into()), ("guild_line", "[IRON] Iron Wolves".into())],
"member" => vec![("player", me), ("uuid", String::new()), ("avatar", String::new()), ("members_total", "128".into())],
_ => vec![("guild", "Iron Wolves".into()), ("tag", "IRON".into()), ("leader", me), ("leader_avatar", String::new()), ("guild_description", "Defenders of the realm.".into()),
("members", "1".into()), ("claims", "0".into()), ("guilds_total", "12".into())],
};
for (k, val) in pairs {
v.insert(k.into(), val);
}
v
}
// ---------------------------------------------------------------------------
// Sending
// ---------------------------------------------------------------------------
pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()> {
let resp = state.http.post(webhook).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
}
Ok(())
}
/// Post a new message and return its id (so it can be edited later).
async fn post_returning_id(state: &AppState, webhook: &str, body: &Value) -> AppResult<String> {
let resp = state.http.post(format!("{webhook}?wait=true")).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
}
let v: Value = resp.json().await.map_err(|_| AppError::bad_request("Discord sent an unexpected answer"))?;
Ok(v["id"].as_str().unwrap_or_default().to_string())
}
pub async fn announce_event(state: &AppState, kind: &str, vars: &Vars) -> AppResult<bool> {
let s = discord::load(state).await?;
if s.webhook_url.is_empty() {
return Ok(false);
}
let style = templates(state).await?.remove(kind).unwrap_or_default();
if !style.enabled {
return Ok(false);
}
post(state, &s.webhook_url, &render(&style, vars, None)).await?;
Ok(true)
}
// ---------------------------------------------------------------------------
// Live embeds
// ---------------------------------------------------------------------------
pub const LIVE_KINDS: [&str; 4] = ["status", "guilds", "leaderboard", "baltop"];
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
#[serde(default)]
pub struct LiveConfig {
pub style: EmbedStyle,
/// Where to post. Empty = the announcement webhook.
pub webhook_url: String,
/// Seconds between updates (at least 60).
pub interval_secs: u64,
/// Rows shown (leaderboards, guilds).
pub limit: u32,
/// leaderboard: `playtime`, `level`, `kills`, `blocks`.
pub sort: String,
/// baltop: the economy of this server (0 = the first one).
pub server_id: i64,
/// One line per row; `{rows}` in the description is replaced by them.
pub row: String,
#[serde(skip_serializing)]
pub message_id: String,
}
impl Default for LiveConfig {
fn default() -> Self {
Self { style: EmbedStyle::default(), webhook_url: String::new(), interval_secs: 120, limit: 10, sort: "playtime".into(), server_id: 0, row: String::new(), message_id: String::new() }
}
}
pub fn default_live() -> BTreeMap<String, LiveConfig> {
let mut m = BTreeMap::new();
m.insert("status".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false,
title: "🟢 Server status".into(),
description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(),
color: "#34d399".into(), footer: "{server_name} · updated".into(), ..Default::default()
},
row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(),
..Default::default()
});
m.insert("guilds".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "🛡️ Active guilds".into(), description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(),
color: "#22d3ee".into(), footer: "{server_name}".into(), ..Default::default()
},
row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(),
..Default::default()
});
m.insert("leaderboard".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "🏆 Leaderboard — {sort_label}".into(), description: "{rows}".into(),
color: "#fcd34d".into(), footer: "{server_name}".into(), ..Default::default()
},
row: "`{rank}.` **{player}** — {value}".into(),
..Default::default()
});
m.insert("baltop".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "💰 Richest players".into(), description: "{rows}\n\nTotal in circulation: **{total}**".into(),
color: "#f59e0b".into(), footer: "{server_name}".into(), ..Default::default()
},
row: "`{rank}.` **{player}** — {balance}".into(),
..Default::default()
});
m
}
pub async fn live_configs(state: &AppState) -> AppResult<BTreeMap<String, LiveConfig>> {
let mut stored: BTreeMap<String, LiveConfig> = store::kv_get(state, "discord_live").await?;
for (k, v) in default_live() {
stored.entry(k).or_insert(v);
}
Ok(stored)
}
pub async fn save_live(state: &AppState, configs: &BTreeMap<String, LiveConfig>) -> AppResult<()> {
// message_id is runtime state; keep it out of the JSON the admin edits but in storage.
let mut stored = serde_json::Map::new();
for (k, c) in configs {
let mut v = serde_json::to_value(c)?;
v["message_id"] = json!(c.message_id);
stored.insert(k.clone(), v);
}
store::kv_set(state, "discord_live", &Value::Object(stored)).await
}
fn money(v: f64) -> String {
let whole = v.round() as i64;
let s = whole.abs().to_string();
let mut out = String::new();
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(ch);
}
format!("${}{}", if whole < 0 { "-" } else { "" }, out.chars().rev().collect::<String>())
}
fn hours(secs: i64) -> String {
if secs >= 3600 { format!("{}h {}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) }
}
/// The embed body for one live kind, built from current data.
pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppResult<Value> {
let mut vars = common(state).await;
let mut rows: Vec<String> = Vec::new();
let default_row = default_live().remove(kind).map(|c| c.row).unwrap_or_default();
let row_tpl = if cfg.row.trim().is_empty() { default_row } else { cfg.row.clone() };
let limit = cfg.limit.clamp(1, 25) as i64;
match kind {
"status" => {
let list = crate::routes::servers::status_rows(state).await?;
vars.insert("servers_total".into(), list.len().to_string());
vars.insert("servers_online".into(), list.iter().filter(|s| s.online).count().to_string());
vars.insert("players_online".into(), list.iter().map(|s| s.players).sum::<i64>().to_string());
vars.insert("players_max".into(), list.iter().map(|s| s.max_players).sum::<i64>().to_string());
for s in list {
let mut r = vars.clone();
r.insert("status_icon".into(), if s.online { "🟢" } else { "🔴" }.into());
r.insert("server".into(), plain(&s.name));
r.insert("status".into(), if s.online { "Online" } else { "Offline" }.into());
r.insert("players".into(), s.players.to_string());
r.insert("max".into(), if s.max_players > 0 { s.max_players.to_string() } else { "–".into() });
r.insert("tps".into(), s.tps.map(|t| format!("{t:.1}")).unwrap_or_else(|| "–".into()));
r.insert("version".into(), plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) }));
r.insert("last_seen".into(), s.last_seen.as_deref().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| format!("<t:{}:R>", t.timestamp())).unwrap_or_else(|| "never".into()));
rows.push(fill(&row_tpl, &r));
}
}
"guilds" => {
let list: Vec<(String, String, i64, i64, i64, String, String)> = sqlx::query_as(
"SELECT g.name, g.tag, g.level, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id), COALESCE(u.username, ''), g.leader_uuid
FROM guilds g LEFT JOIN users u ON u.uuid = g.leader_uuid ORDER BY g.level DESC, g.xp DESC, 4 DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&state.db)
.await?;
let totals: (i64, i64) = sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?;
vars.insert("guilds_total".into(), totals.0.to_string());
vars.insert("claims_total".into(), totals.1.to_string());
for (i, (name, tag, level, members, claims, leader, leader_uuid)) in list.into_iter().enumerate() {
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("guild", plain(&name)), ("tag", plain(&tag)), ("level", level.to_string()), ("members", members.to_string()),
("claims", claims.to_string()), ("leader", plain(&leader)), ("leader_avatar", avatar(state, &leader_uuid))] {
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
}
}
"leaderboard" => {
let (sort_label, order, unit) = match cfg.sort.as_str() {
"level" => ("Level", "COALESCE(l.global_xp, 0)", "level"),
"kills" => ("Player kills", "kills", "kills"),
"blocks" => ("Blocks broken", "blocks", "blocks"),
_ => ("Playtime", "playtime", "playtime"),
};
vars.insert("sort_label".into(), sort_label.into());
let sql = format!(
"SELECT s.uuid, COALESCE(u.username, MAX(s.name)), SUM(s.playtime_secs) AS playtime, SUM(s.player_kills) AS kills, SUM(s.blocks_broken) AS blocks, COALESCE(l.global_level, 1), SUM(s.deaths), SUM(s.mob_kills)
FROM player_stats s LEFT JOIN users u ON u.uuid = s.uuid LEFT JOIN user_levels l ON l.uuid = s.uuid GROUP BY s.uuid ORDER BY {order} DESC LIMIT ?"
);
let list: Vec<(String, String, i64, i64, i64, i64, i64, i64)> = sqlx::query_as(&sql).bind(limit).fetch_all(&state.db).await?;
for (i, (uuid, name, playtime, kills, blocks, level, deaths, mobs)) in list.into_iter().enumerate() {
let value = match unit { "level" => format!("level {level}"), "kills" => format!("{kills} kills"), "blocks" => format!("{blocks} blocks"), _ => hours(playtime) };
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("value", value), ("playtime", hours(playtime)),
("kills", kills.to_string()), ("blocks", blocks.to_string()), ("level", level.to_string()), ("deaths", deaths.to_string()), ("mob_kills", mobs.to_string())] {
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
}
}
_ => {
let sid: i64 = if cfg.server_id > 0 { cfg.server_id } else { sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await? };
let economy = crate::routes::servers::economy_scope(&state.db, sid).await?;
let list: Vec<(String, String, f64)> = sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?").bind(economy).bind(limit).fetch_all(&state.db).await?;
let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?").bind(economy).fetch_one(&state.db).await?;
vars.insert("total".into(), money(total));
for (i, (uuid, name, balance)) in list.into_iter().enumerate() {
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))] {
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
}
}
}
let rows_text = if rows.is_empty() { "*Nothing to show yet.*".to_string() } else { rows.join("\n") };
Ok(render(&cfg.style, &vars, Some(&rows_text)))
}
/// Post or update one live embed. Returns the stored message id.
pub async fn refresh_live(state: &AppState, kind: &str, force_new: bool) -> AppResult<()> {
let mut all = live_configs(state).await?;
let Some(cfg) = all.get(kind).cloned() else { return Err(AppError::bad_request("unknown embed")) };
let main = discord::load(state).await?;
let hook = if cfg.webhook_url.trim().is_empty() { main.webhook_url.clone() } else { cfg.webhook_url.trim().to_string() };
if hook.is_empty() || !discord::valid_webhook(&hook) {
return Err(AppError::bad_request("set a Discord webhook first (Discord settings, or this embed's own webhook)"));
}
let body = build_live(state, kind, &cfg).await?;
let mut id = if force_new { String::new() } else { cfg.message_id.clone() };
if !id.is_empty() {
let resp = state.http.patch(format!("{hook}/messages/{id}")).json(&body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
id.clear(); // the message was deleted in Discord: post a fresh one
} else if !resp.status().is_success() {
return Err(AppError::bad_request(format!("Discord refused the update (HTTP {})", resp.status().as_u16())));
}
}
if id.is_empty() {
id = post_returning_id(state, &hook, &body).await?;
}
if let Some(c) = all.get_mut(kind) {
c.message_id = id;
}
save_live(state, &all).await
}
/// Called every half minute: refresh the embeds that are due.
pub async fn tick_live(state: &AppState, last: &mut BTreeMap<String, std::time::Instant>) {
let Ok(all) = live_configs(state).await else { return };
for (kind, cfg) in all {
if !cfg.style.enabled {
continue;
}
let every = Duration::from_secs(cfg.interval_secs.max(60));
if last.get(&kind).is_some_and(|t| t.elapsed() < every) {
continue;
}
last.insert(kind.clone(), std::time::Instant::now());
if let Err(e) = refresh_live(state, &kind, false).await {
tracing::debug!("live embed {kind}: {}", e.message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholders_are_filled_and_unknown_ones_kept() {
let mut v = Vars::new();
v.insert("player".into(), "Steve".into());
assert_eq!(fill("Hi {player}, {nope} and {a b} {", &v), "Hi Steve, {nope} and {a b} {");
assert_eq!(fill("{player}{player}", &v), "SteveSteve");
}
#[test]
fn embeds_stay_inside_discords_limits_and_drop_empties() {
let mut v = Vars::new();
v.insert("long".into(), "x".repeat(5000));
v.insert("img".into(), "javascript:alert(1)".into());
let style = EmbedStyle { title: "{long}".into(), description: "{long}".into(), thumbnail: "{img}".into(), color: "nonsense".into(), fields: vec![field("", "empty name", false), field("ok", "{long}", true)], ..Default::default() };
let body = render(&style, &v, None);
let e = &body["embeds"][0];
assert!(e["title"].as_str().unwrap().chars().count() <= 256);
assert!(e["description"].as_str().unwrap().chars().count() <= 4096);
assert!(e.get("thumbnail").is_none(), "only web addresses become images");
assert_eq!(e["color"], 0x8b6cff);
assert_eq!(e["fields"].as_array().unwrap().len(), 1);
assert!(e["fields"][0]["value"].as_str().unwrap().chars().count() <= 1024);
}
#[test]
fn player_text_cannot_ping() {
assert_eq!(plain("@everyone `x`"), "@\u{200b}everyone 'x'");
}
#[test]
fn money_and_hours_read_well() {
assert_eq!(money(1234567.4), "$1,234,567");
assert_eq!(hours(3700), "1h 1m");
assert_eq!(hours(300), "5m");
}
}
+4 -2
View File
@@ -6,12 +6,14 @@
pub mod auth;
pub mod config;
pub mod db;
pub mod embeds;
pub mod error;
pub mod livemap;
pub mod worldmap;
pub mod net;
pub mod packs;
pub mod progression;
pub mod purge;
pub mod rewards;
pub mod routes;
pub mod seed;
pub mod state;
@@ -63,7 +65,7 @@ pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, yg
keys: Arc::new(auth::Keys::new(&secret)),
http: scopenet_core::http::client(),
login_guard: Arc::new(auth::LoginGuard::default()),
livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir)),
worldmap: Arc::new(worldmap::WorldMap::new(&cfg.data_dir)),
cfg: Arc::new(cfg),
})
}
-442
View File
@@ -1,442 +0,0 @@
//! Live player positions and the legacy world mirror received from game servers.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const SECTOR: usize = 4096;
const HEADER: usize = 2 * SECTOR;
/// A chunk that needs more sectors than a location entry can address lives in
/// an external `.mcc` file; the mirror skips those (they are vanishingly rare).
const MAX_SECTORS: usize = 255;
/// Sidecars idle for this long are stopped and restarted on the next view.
pub const IDLE_STOP: Duration = Duration::from_secs(15 * 60);
/// Player positions older than this are treated as "server stopped sending".
const PLAYERS_FRESH: Duration = Duration::from_secs(45);
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Dim {
Overworld,
Nether,
End,
}
impl Dim {
pub const ALL: [Dim; 3] = [Dim::Overworld, Dim::Nether, Dim::End];
/// Accepts the short slug (`the_nether`) or the namespaced id.
pub fn parse(s: &str) -> Option<Dim> {
match s.trim().trim_start_matches("minecraft:") {
"overworld" => Some(Dim::Overworld),
"the_nether" | "nether" => Some(Dim::Nether),
"the_end" | "end" => Some(Dim::End),
_ => None,
}
}
pub fn slug(self) -> &'static str {
match self {
Dim::Overworld => "overworld",
Dim::Nether => "the_nether",
Dim::End => "the_end",
}
}
pub fn id(self) -> String {
format!("minecraft:{}", self.slug())
}
/// Folder inside a vanilla save.
fn folder(self) -> &'static str {
match self {
Dim::Overworld => "",
Dim::Nether => "DIM-1",
Dim::End => "DIM1",
}
}
}
// ---------------------------------------------------------------------------
// Anvil region writer
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Chunk {
pub x: i32,
pub z: i32,
/// Seconds since epoch, as stored in the region header.
pub timestamp: u32,
pub compression: u8,
pub data: Vec<u8>,
}
pub fn region_path(dir: &Path, rx: i32, rz: i32) -> PathBuf {
dir.join(format!("r.{rx}.{rz}.mca"))
}
fn slot(x: i32, z: i32) -> usize {
(x.rem_euclid(32) + z.rem_euclid(32) * 32) as usize
}
fn sectors_for(len: usize) -> usize {
len.div_ceil(SECTOR)
}
fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
/// Write `chunks` (all in one region) into `path`, creating it if needed.
/// Chunks older than what the file already holds are ignored. Returns the
/// number of chunks stored.
pub fn write_region(path: &Path, chunks: &[Chunk]) -> std::io::Result<usize> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?;
let mut len = file.metadata()?.len() as usize;
let mut header = vec![0u8; HEADER];
if len >= HEADER {
file.read_exact(&mut header)?;
} else {
file.set_len(0)?;
file.write_all(&header)?;
len = HEADER;
}
let mut end = len.div_ceil(SECTOR);
let mut stored = 0;
for c in chunks {
let i = slot(c.x, c.z);
let old_ts = be32(&header[SECTOR + i * 4..]);
if old_ts != 0 && old_ts > c.timestamp {
continue;
}
let body = c.data.len() + 1;
let count = sectors_for(body + 4);
if count > MAX_SECTORS || c.compression & 0x80 != 0 || c.data.is_empty() {
continue;
}
let mut record = Vec::with_capacity(count * SECTOR);
record.extend_from_slice(&(body as u32).to_be_bytes());
record.push(c.compression);
record.extend_from_slice(&c.data);
record.resize(count * SECTOR, 0);
file.seek(SeekFrom::Start((end * SECTOR) as u64))?;
file.write_all(&record)?;
let loc = ((end as u32) << 8) | count as u32;
header[i * 4..i * 4 + 4].copy_from_slice(&loc.to_be_bytes());
header[SECTOR + i * 4..SECTOR + i * 4 + 4].copy_from_slice(&c.timestamp.to_be_bytes());
end += count;
stored += 1;
}
file.seek(SeekFrom::Start(0))?;
file.write_all(&header)?;
file.flush()?;
drop(file);
// Rewriting a chunk appends it, leaving its old sectors behind. Compact
// once more than half the file is dead space.
let live: usize = (0..1024).map(|i| (be32(&header[i * 4..]) & 0xff) as usize).sum();
if stored > 0 && end > 2 * (live + 2) + 64 {
compact(path)?;
}
Ok(stored)
}
fn compact(path: &Path) -> std::io::Result<()> {
let data = std::fs::read(path)?;
if data.len() < HEADER {
return Ok(());
}
let mut header = vec![0u8; HEADER];
header[SECTOR..].copy_from_slice(&data[SECTOR..HEADER]);
let mut body: Vec<u8> = Vec::new();
let mut next = 2usize;
for i in 0..1024 {
let loc = be32(&data[i * 4..]);
let (off, count) = ((loc >> 8) as usize, (loc & 0xff) as usize);
if count == 0 || (off + count) * SECTOR > data.len() {
continue;
}
body.extend_from_slice(&data[off * SECTOR..(off + count) * SECTOR]);
header[i * 4..i * 4 + 4].copy_from_slice(&(((next as u32) << 8) | count as u32).to_be_bytes());
next += count;
}
let tmp = path.with_extension("mca.tmp");
let mut out = std::fs::File::create(&tmp)?;
out.write_all(&header)?;
out.write_all(&body)?;
out.flush()?;
drop(out);
std::fs::rename(tmp, path)
}
/// The 4 KiB timestamp table of a region file, or `None` if it doesn't exist.
pub fn read_timestamps(path: &Path) -> Option<Vec<u8>> {
let mut f = std::fs::File::open(path).ok()?;
f.seek(SeekFrom::Start(SECTOR as u64)).ok()?;
let mut buf = vec![0u8; SECTOR];
f.read_exact(&mut buf).ok()?;
Some(buf)
}
/// Parse a batch upload: repeated `i32 x, i32 z, u32 timestamp, u8
/// compression, u32 length, bytes` records, big-endian.
pub fn parse_chunks(mut b: &[u8]) -> Result<Vec<Chunk>, &'static str> {
let mut out = Vec::new();
while !b.is_empty() {
if b.len() < 17 {
return Err("truncated chunk record");
}
let x = be32(b) as i32;
let z = be32(&b[4..]) as i32;
let timestamp = be32(&b[8..]);
let compression = b[12];
let len = be32(&b[13..]) as usize;
b = &b[17..];
if len > MAX_SECTORS * SECTOR || len > b.len() {
return Err("chunk record exceeds its payload");
}
if x.unsigned_abs() > 3_750_000 || z.unsigned_abs() > 3_750_000 {
return Err("chunk coordinates are outside the world");
}
out.push(Chunk { x, z, timestamp, compression, data: b[..len].to_vec() });
b = &b[len..];
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Live players
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LivePlayer {
pub uuid: String,
pub name: String,
#[serde(default)]
pub dimension: String,
pub x: f64,
pub y: f64,
pub z: f64,
#[serde(default)]
pub yaw: f64,
#[serde(default)]
pub pitch: f64,
}
struct Snapshot {
at: Instant,
players: Vec<LivePlayer>,
}
// ---------------------------------------------------------------------------
#[derive(Default, Clone, Serialize)]
pub struct ServerStats {
pub chunks_received: u64,
pub last_chunk_at: Option<String>,
pub last_players_at: Option<String>,
}
pub struct LiveMap {
root: PathBuf,
players: Mutex<HashMap<i64, Snapshot>>,
stats: Mutex<HashMap<i64, ServerStats>>,
write_lock: tokio::sync::Mutex<()>,
}
impl LiveMap {
pub fn new(data_dir: &Path) -> Self {
Self {
root: data_dir.join("livemap"),
players: Mutex::default(),
stats: Mutex::default(),
write_lock: Default::default(),
}
}
pub fn server_dir(&self, id: i64) -> PathBuf {
self.root.join(id.to_string())
}
pub fn world_dir(&self, id: i64) -> PathBuf {
self.server_dir(id).join("world")
}
pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf {
let w = self.world_dir(id);
if dim.folder().is_empty() {
w.join("region")
} else {
w.join(dim.folder()).join("region")
}
}
// ---- ingest ----
/// Write a batch of chunks into the server's mirror. Returns how many
/// chunks were stored.
pub async fn ingest_chunks(&self, id: i64, dim: Dim, chunks: Vec<Chunk>) -> std::io::Result<usize> {
let dir = self.region_dir(id, dim);
let _guard = self.write_lock.lock().await;
let stored = tokio::task::spawn_blocking(move || {
let mut by_region: HashMap<(i32, i32), Vec<Chunk>> = HashMap::new();
for c in chunks {
by_region.entry((c.x.div_euclid(32), c.z.div_euclid(32))).or_default().push(c);
}
let mut stored = 0;
for ((rx, rz), list) in by_region {
stored += write_region(&region_path(&dir, rx, rz), &list)?;
}
Ok::<_, std::io::Error>(stored)
})
.await
.map_err(std::io::Error::other)??;
let mut stats = self.stats.lock().unwrap();
let s = stats.entry(id).or_default();
s.chunks_received += stored as u64;
if stored > 0 {
s.last_chunk_at = Some(crate::db::now());
}
Ok(stored)
}
pub async fn ingest_level(&self, id: i64, bytes: Vec<u8>) -> std::io::Result<()> {
let dir = self.world_dir(id);
tokio::fs::create_dir_all(&dir).await?;
let tmp = dir.join("level.dat.tmp");
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(tmp, dir.join("level.dat")).await
}
/// Timestamp tables for every mirrored region of one dimension, so the
/// game server only uploads chunks that are newer.
pub fn manifest(&self, id: i64, dim: Dim) -> Vec<(i32, i32, Vec<u8>)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) else { return out };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some(rest) = name.strip_prefix("r.").and_then(|r| r.strip_suffix(".mca")) else { continue };
let Some((a, b)) = rest.split_once('.') else { continue };
let (Ok(rx), Ok(rz)) = (a.parse::<i32>(), b.parse::<i32>()) else { continue };
if let Some(ts) = read_timestamps(&e.path()) {
out.push((rx, rz, ts));
}
}
out
}
pub fn set_players(&self, id: i64, players: Vec<LivePlayer>) {
self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players });
self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(crate::db::now());
}
/// Drop a player from every server's live roster (account deleted).
pub fn forget_player(&self, uuid: &str) {
for snap in self.players.lock().unwrap().values_mut() {
snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid));
}
}
pub fn live_players(&self, id: i64) -> Vec<LivePlayer> {
self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default()
}
pub fn stats(&self, id: i64) -> ServerStats {
self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default()
}
/// Disk usage and regions per dimension.
pub fn world_summary(&self, id: i64) -> serde_json::Value {
let mut dims = Vec::new();
for dim in Dim::ALL {
let (mut regions, mut bytes) = (0u64, 0u64);
if let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) {
for e in rd.flatten() {
if let Ok(m) = e.metadata() {
regions += 1;
bytes += m.len();
}
}
}
dims.push(serde_json::json!({ "id": dim.id(), "slug": dim.slug(), "regions": regions, "bytes": bytes }));
}
serde_json::json!({ "level_dat": self.world_dir(id).join("level.dat").is_file(), "dimensions": dims })
}
/// Clear the legacy world mirror and current player positions.
pub async fn reset(&self, id: i64) -> std::io::Result<()> {
let _guard = self.write_lock.lock().await;
self.players.lock().unwrap().remove(&id);
self.stats.lock().unwrap().remove(&id);
match tokio::fs::remove_dir_all(self.server_dir(id)).await {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(x: i32, z: i32, ts: u32, byte: u8, len: usize) -> Chunk {
Chunk { x, z, timestamp: ts, compression: 2, data: vec![byte; len] }
}
fn read_chunk(path: &Path, x: i32, z: i32) -> Option<(u32, Vec<u8>)> {
let data = std::fs::read(path).ok()?;
let i = slot(x, z);
let loc = be32(&data[i * 4..]);
if loc == 0 {
return None;
}
let off = (loc >> 8) as usize * SECTOR;
let len = be32(&data[off..]) as usize;
Some((be32(&data[SECTOR + i * 4..]), data[off + 5..off + 4 + len].to_vec()))
}
#[test]
fn writes_reads_and_rewrites_chunks() {
let dir = tempfile::tempdir().unwrap();
let path = region_path(dir.path(), 0, 0);
assert_eq!(write_region(&path, &[chunk(1, 2, 10, 7, 5000), chunk(-1 + 32, 0, 10, 9, 10)]).unwrap(), 2);
assert_eq!(read_chunk(&path, 1, 2), Some((10, vec![7; 5000])));
// Newer replaces, older is ignored.
assert_eq!(write_region(&path, &[chunk(1, 2, 20, 8, 100)]).unwrap(), 1);
assert_eq!(write_region(&path, &[chunk(1, 2, 5, 1, 100)]).unwrap(), 0);
assert_eq!(read_chunk(&path, 1, 2), Some((20, vec![8; 100])));
assert_eq!(read_chunk(&path, 31, 0), Some((10, vec![9; 10])));
assert_eq!(be32(&read_timestamps(&path).unwrap()[slot(1, 2) * 4..]), 20);
}
#[test]
fn compaction_keeps_live_chunks() {
let dir = tempfile::tempdir().unwrap();
let path = region_path(dir.path(), -1, 3);
for ts in 1..40u32 {
write_region(&path, &[chunk(-32, 96, ts, ts as u8, 20_000)]).unwrap();
}
write_region(&path, &[chunk(-31, 96, 1, 5, 50)]).unwrap();
assert!(std::fs::metadata(&path).unwrap().len() < 40 * 20_000 / 2);
assert_eq!(read_chunk(&path, -32, 96), Some((39, vec![39; 20_000])));
assert_eq!(read_chunk(&path, -31, 96), Some((1, vec![5; 50])));
}
#[test]
fn parses_upload_batches() {
let mut b = Vec::new();
b.extend_from_slice(&(-3i32).to_be_bytes());
b.extend_from_slice(&4i32.to_be_bytes());
b.extend_from_slice(&99u32.to_be_bytes());
b.push(2);
b.extend_from_slice(&3u32.to_be_bytes());
b.extend_from_slice(&[1, 2, 3]);
let chunks = parse_chunks(&b).unwrap();
assert_eq!((chunks[0].x, chunks[0].z, chunks[0].timestamp, chunks[0].data.clone()), (-3, 4, 99, vec![1, 2, 3]));
assert!(parse_chunks(&b[..b.len() - 1]).is_err());
}
#[test]
fn dimensions_parse() {
assert_eq!(Dim::parse("minecraft:the_nether"), Some(Dim::Nether));
assert_eq!(Dim::parse("overworld"), Some(Dim::Overworld));
assert_eq!(Dim::parse("../etc"), None);
}
}
+12
View File
@@ -23,6 +23,18 @@ async fn main() -> anyhow::Result<()> {
let state = build_state(cfg, pool).await?;
bootstrap_admin(&state).await?;
scopenet_panel::routes::discord::spawn_worker(state.clone());
// Earned rewards are applied within seconds even when no game server happens to be polling.
{
let state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Err(e) = scopenet_panel::rewards::process_queue(&state).await {
tracing::warn!("reward queue failed: {:?}", e.message);
}
}
});
}
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION"));
+50
View File
@@ -48,6 +48,32 @@ pub struct Progression {
pub global_xp_multiplier: f64,
pub server_xp_multiplier: f64,
pub xp_rates: XpRates,
/// Economy and guild rules that apply on every server.
pub rules: Rules,
}
/// Network-wide limits an admin can change without touching a server. 0 means "no limit" where it says so.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Rules {
/// Balance a player starts with on each server economy.
pub starting_balance: f64,
/// Chunks a new guild may claim.
pub guild_base_claims: i64,
/// Extra claimable chunks for every member beyond the leader.
pub guild_claims_per_member: i64,
/// Extra claimable chunks for every guild level above 1.
pub guild_claims_per_level: i64,
/// Most members a guild may have (0 = no limit).
pub guild_max_members: i64,
/// Most listings one player may have on a server's market (0 = no limit).
pub market_max_listings: i64,
}
impl Default for Rules {
fn default() -> Self {
Self { starting_balance: 1000.0, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 }
}
}
impl Default for Progression {
@@ -62,6 +88,7 @@ impl Default for Progression {
global_xp_multiplier: 1.0,
server_xp_multiplier: 1.0,
xp_rates: XpRates::default(),
rules: Rules::default(),
}
}
}
@@ -89,6 +116,21 @@ impl Progression {
return Err(AppError::bad_request(format!("{name} must be between 0 and 100.")));
}
}
let rules = &self.rules;
if !rules.starting_balance.is_finite() || !(0.0..=1e9).contains(&rules.starting_balance) {
return bad("Starting balance must be between 0 and 1,000,000,000.");
}
for (name, v, max) in [
("Guild base claims", rules.guild_base_claims, 100_000),
("Claims per member", rules.guild_claims_per_member, 10_000),
("Claims per guild level", rules.guild_claims_per_level, 10_000),
("Guild member limit", rules.guild_max_members, 10_000),
("Market listing limit", rules.market_max_listings, 10_000),
] {
if !(0..=max).contains(&v) {
return Err(AppError::bad_request(format!("{name} must be between 0 and {max}.")));
}
}
let r = &self.xp_rates;
for (name, v) in [
("Playtime XP", r.playtime_per_hour),
@@ -196,6 +238,14 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
.bind(serde_json::to_string(p)?)
.execute(pool)
.await?;
// The same rules as plain rows, so SQL can use them (guild claim limits are worked out in queries).
for (key, value) in [
("rule_claims_per_member", p.rules.guild_claims_per_member),
("rule_claims_per_level", p.rules.guild_claims_per_level),
("rule_guild_max_members", p.rules.guild_max_members),
] {
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(key).bind(value.to_string()).execute(pool).await?;
}
Ok(())
}
+1 -1
View File
@@ -157,6 +157,6 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
}
}
// Live map: stop showing them.
state.livemap.forget_player(uuid);
state.worldmap.forget_player(uuid);
Ok(report)
}
+293
View File
@@ -0,0 +1,293 @@
//! Rewards beyond XP and titles.
//!
//! An admin attaches a *bundle* of actions to a quest, an achievement or a rank milestone (a level reward). When a
//! player earns one, the source goes into `reward_queue`; [`process_queue`] then applies each action: money, claim
//! chunks and badges happen in the panel, while items, permissions, groups, commands and messages become
//! `reward_deliveries` that the player's game server collects (once the player is online) and acknowledges.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use serde_json::{json, Map, Value};
use sqlx::SqliteConnection;
pub const MAX_ACTIONS: usize = 20;
const SOURCES: [&str; 3] = ["quest", "achievement", "level_reward"];
pub fn check_source(kind: &str) -> AppResult<()> {
if SOURCES.contains(&kind) { Ok(()) } else { Err(AppError::bad_request("unknown reward source")) }
}
fn text(a: &Map<String, Value>, key: &str, max: usize) -> String {
a.get(key).and_then(Value::as_str).map(|s| s.trim().chars().filter(|c| !c.is_control()).take(max).collect()).unwrap_or_default()
}
fn int(a: &Map<String, Value>, key: &str) -> Option<i64> {
a.get(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
}
fn word(value: &str, extra: &str) -> bool {
!value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
}
/// Validate and normalise an admin's list of actions. Unknown fields are dropped, so nothing unexpected is stored.
pub fn validate(actions: &Value) -> AppResult<Vec<Value>> {
let list = actions.as_array().ok_or_else(|| AppError::bad_request("actions must be a list"))?;
if list.len() > MAX_ACTIONS {
return Err(AppError::bad_request(format!("at most {MAX_ACTIONS} rewards per bundle")));
}
let mut out = Vec::new();
for raw in list {
let a = raw.as_object().ok_or_else(|| AppError::bad_request("each reward must be an object"))?;
let kind = text(a, "type", 24);
let server = int(a, "server_id").filter(|s| *s > 0);
let mut v = Map::new();
v.insert("type".into(), json!(kind));
if let Some(s) = server {
v.insert("server_id".into(), json!(s));
}
match kind.as_str() {
"money" => {
let amount = a.get("amount").and_then(Value::as_f64).unwrap_or(0.0);
if !amount.is_finite() || amount <= 0.0 || amount > 1e9 {
return Err(AppError::bad_request("money must be more than 0"));
}
v.insert("amount".into(), json!((amount * 100.0).round() / 100.0));
}
"item" => {
let mut item = text(a, "item", 100).to_lowercase();
if !item.contains(':') {
item = format!("minecraft:{item}");
}
let (ns, path) = item.split_once(':').unwrap_or(("", ""));
if !word(ns, "_.-") || !word(path, "_.-/") {
return Err(AppError::bad_request("item must look like minecraft:diamond"));
}
let amount = int(a, "amount").unwrap_or(1);
if !(1..=6400).contains(&amount) {
return Err(AppError::bad_request("item amount must be 1 to 6400"));
}
v.insert("item".into(), json!(item));
v.insert("amount".into(), json!(amount));
}
"permission" => {
let node = text(a, "node", 100);
if !word(&node, "_.*-:") {
return Err(AppError::bad_request("permission node has unusual characters"));
}
let minutes = int(a, "minutes").unwrap_or(0);
if !(0..=5_256_000).contains(&minutes) {
return Err(AppError::bad_request("permission duration is out of range"));
}
v.insert("node".into(), json!(node));
v.insert("value".into(), json!(a.get("value").and_then(Value::as_bool).unwrap_or(true)));
v.insert("minutes".into(), json!(minutes));
}
"group" => {
let group = text(a, "group", 64);
if !word(&group, "_.-") {
return Err(AppError::bad_request("group name has unusual characters"));
}
v.insert("group".into(), json!(group));
}
"claim_chunks" => {
let amount = int(a, "amount").unwrap_or(0);
if !(1..=10_000).contains(&amount) {
return Err(AppError::bad_request("claim chunks must be 1 to 10000"));
}
v.insert("amount".into(), json!(amount));
}
"badge" => {
let badge = text(a, "badge", 40);
if badge.is_empty() {
return Err(AppError::bad_request("badge needs a name"));
}
v.insert("badge".into(), json!(badge));
}
"message" => {
let message = text(a, "text", 200);
if message.is_empty() {
return Err(AppError::bad_request("message can't be empty"));
}
v.insert("text".into(), json!(message));
}
"command" => {
let command = text(a, "command", 256).trim_start_matches('/').to_string();
if command.is_empty() {
return Err(AppError::bad_request("command can't be empty"));
}
v.insert("command".into(), json!(command));
}
_ => return Err(AppError::bad_request(format!("unknown reward type '{kind}'"))),
}
out.push(Value::Object(v));
}
Ok(out)
}
fn pretty_item(id: &str) -> String {
let name = id.rsplit(':').next().unwrap_or(id).replace(['_', '/'], " ");
name.split_whitespace().map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() }).collect::<Vec<_>>().join(" ")
}
/// A short, player-facing line for one action (commands stay vague on purpose).
pub fn describe(a: &Value) -> String {
let n = |k: &str| a[k].as_i64().unwrap_or(0);
match a["type"].as_str().unwrap_or("") {
"money" => format!("${:.2}", a["amount"].as_f64().unwrap_or(0.0)),
"item" => format!("{} x{}", pretty_item(a["item"].as_str().unwrap_or("")), n("amount")),
"permission" => {
let m = n("minutes");
let span = if m == 0 { String::new() } else if m % 1440 == 0 { format!(" for {} day{}", m / 1440, if m / 1440 == 1 { "" } else { "s" }) } else if m % 60 == 0 { format!(" for {}h", m / 60) } else { format!(" for {m}m") };
format!("Permission: {}{}", a["node"].as_str().unwrap_or(""), span)
}
"group" => format!("Group: {}", a["group"].as_str().unwrap_or("")),
"claim_chunks" => format!("+{} claim chunks", n("amount")),
"badge" => format!("Badge: {}", a["badge"].as_str().unwrap_or("")),
"message" => "A message".to_string(),
_ => "A special reward".to_string(),
}
}
pub async fn bundle(conn: &mut SqliteConnection, kind: &str, id: &str) -> AppResult<Vec<Value>> {
let raw: Option<String> = sqlx::query_scalar("SELECT actions FROM reward_bundles WHERE source_type = ? AND source_id = ?").bind(kind).bind(id).fetch_optional(&mut *conn).await?;
Ok(raw.and_then(|r| serde_json::from_str::<Vec<Value>>(&r).ok()).unwrap_or_default())
}
/// Queue a bundle for a player who just earned its source. Does nothing when the source has no bundle.
pub async fn enqueue(conn: &mut SqliteConnection, uuid: &str, kind: &str, id: &str) -> AppResult<()> {
let has: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reward_bundles WHERE source_type = ? AND source_id = ? AND actions <> '[]')").bind(kind).bind(id).fetch_one(&mut *conn).await?;
if has {
sqlx::query("INSERT INTO reward_queue (uuid, source_type, source_id, created_at) VALUES (?, ?, ?, ?)").bind(uuid).bind(kind).bind(id).bind(crate::db::now()).execute(&mut *conn).await?;
}
Ok(())
}
async fn credit(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, name: &str, amount: f64, server: Option<i64>, note: &str) -> AppResult<()> {
// One credit per economy: servers sharing a group share balances, so they must not be paid twice.
let servers: Vec<i64> = match server {
Some(id) => vec![id],
None => sqlx::query_scalar("SELECT id FROM game_servers ORDER BY id").fetch_all(&mut **tx).await?,
};
let mut economies = Vec::new();
for id in servers {
let e: i64 = sqlx::query_scalar(
"SELECT COALESCE((SELECT MIN(b.id) FROM game_servers b WHERE b.economy_group <> '' AND b.economy_group = a.economy_group COLLATE NOCASE), a.id) FROM game_servers a WHERE a.id = ?",
)
.bind(id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(id);
if !economies.contains(&e) {
economies.push(e);
}
}
let now = chrono::Utc::now().to_rfc3339();
for economy in economies {
crate::routes::economy::ensure_balance(tx, economy, uuid, name).await?;
sqlx::query("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ?").bind(amount).bind(&now).bind(economy).bind(uuid).execute(&mut **tx).await?;
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, 'server', 'Rewards', ?, ?, ?, ?, ?)")
.bind(economy).bind(uuid).bind(name).bind(amount).bind(note).bind(&now).execute(&mut **tx).await?;
}
Ok(())
}
async fn source_label(conn: &mut SqliteConnection, kind: &str, id: &str) -> String {
let q = match kind {
"quest" => "SELECT title FROM quests WHERE id = ?",
"achievement" => "SELECT title FROM achievements WHERE id = ?",
_ => "SELECT reward_name FROM level_rewards WHERE CAST(id AS TEXT) = ?",
};
sqlx::query_scalar::<_, String>(q).bind(id).fetch_optional(&mut *conn).await.ok().flatten().unwrap_or_else(|| id.to_string())
}
/// Apply every queued bundle. Safe to call often; each queue row is handled exactly once.
pub async fn process_queue(state: &AppState) -> AppResult<usize> {
let rows: Vec<(i64, String, String, String)> = sqlx::query_as("SELECT id, uuid, source_type, source_id FROM reward_queue ORDER BY id LIMIT 200").fetch_all(&state.db).await?;
let mut done = 0;
for (queue_id, uuid, kind, id) in rows {
let mut tx = state.db.begin().await?;
let gone: Option<i64> = sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?;
if gone.is_none() {
continue; // another request got there first
}
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_default();
if name.is_empty() {
tx.commit().await?;
continue;
}
let label = source_label(&mut tx, &kind, &id).await;
let source = format!("{kind}:{id}");
let now = crate::db::now();
for action in bundle(&mut tx, &kind, &id).await? {
let server = action["server_id"].as_i64();
match action["type"].as_str().unwrap_or("") {
"money" => credit(&mut tx, &uuid, &name, action["amount"].as_f64().unwrap_or(0.0), server, &format!("Reward: {label}")).await?,
"claim_chunks" => {
sqlx::query("INSERT INTO player_bonuses (uuid, claim_chunks) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET claim_chunks = claim_chunks + excluded.claim_chunks")
.bind(&uuid).bind(action["amount"].as_i64().unwrap_or(0)).execute(&mut *tx).await?;
}
"badge" => {
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_else(|| "[]".into());
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
let badge = action["badge"].as_str().unwrap_or("").to_string();
if !badge.is_empty() && !badges.contains(&badge) {
badges.push(badge);
sqlx::query("INSERT INTO user_levels(uuid, badges, updated_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET badges = excluded.badges")
.bind(&uuid).bind(serde_json::to_string(&badges)?).bind(&now).execute(&mut *tx).await?;
}
}
other => {
let mut payload = action.clone();
payload["reason"] = json!(label);
sqlx::query("INSERT INTO reward_deliveries (uuid, server_id, kind, payload, source, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&uuid).bind(server).bind(other).bind(payload.to_string()).bind(&source).bind(&now).execute(&mut *tx).await?;
}
}
}
tx.commit().await?;
done += 1;
}
Ok(done)
}
/// Deliveries this game server should carry out now: for players online here, not yet done, and not already out for delivery.
pub async fn poll(state: &AppState, server_id: i64) -> AppResult<Vec<Value>> {
process_queue(state).await?;
let mut tx = state.db.begin().await?;
let stale = (chrono::Utc::now() - chrono::Duration::seconds(120)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let rows: Vec<(i64, String, String, String, String)> = sqlx::query_as(
"SELECT d.id, d.uuid, COALESCE(u.username, ''), d.kind, d.payload FROM reward_deliveries d
LEFT JOIN users u ON u.uuid = d.uuid
WHERE d.delivered_at IS NULL AND d.attempts < 8 AND (d.server_id IS NULL OR d.server_id = ?)
AND (d.sent_at IS NULL OR d.sent_at < ?)
AND EXISTS (SELECT 1 FROM server_online o WHERE o.server_id = ? AND o.uuid = d.uuid)
ORDER BY d.id LIMIT 50",
)
.bind(server_id)
.bind(&stale)
.bind(server_id)
.fetch_all(&mut *tx)
.await?;
let now = crate::db::now();
for (id, ..) in &rows {
sqlx::query("UPDATE reward_deliveries SET sent_at = ?, attempts = attempts + 1 WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(rows.into_iter().map(|(id, uuid, name, kind, payload)| json!({ "id": id, "uuid": uuid, "name": name, "kind": kind, "payload": serde_json::from_str::<Value>(&payload).unwrap_or(json!({})) })).collect())
}
/// The game server reports what it did. Failures are retried a few times, then kept in the log with the reason.
pub async fn ack(state: &AppState, done: &[i64], failed: &[(i64, String)]) -> AppResult<()> {
let now = crate::db::now();
let mut tx = state.db.begin().await?;
for id in done {
sqlx::query("UPDATE reward_deliveries SET delivered_at = ?, error = NULL WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
}
for (id, error) in failed {
let error: String = error.chars().filter(|c| !c.is_control()).take(200).collect();
// A refused delivery (e.g. commands switched off on that server) is retried a few times, then left for the admin to see.
sqlx::query("UPDATE reward_deliveries SET error = ?, sent_at = ? WHERE id = ? AND delivered_at IS NULL").bind(error).bind(&now).bind(id).execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}
+1
View File
@@ -311,6 +311,7 @@ pub async fn admin_delete_achievement(
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'achievement' AND source_id = ?").bind(&id).execute(&state.db).await?;
sqlx::query("DELETE FROM achievements WHERE id = ?")
.bind(&id)
.execute(&state.db)
+12 -7
View File
@@ -27,7 +27,7 @@ pub struct ConnectionsSettings {
pub sender_name: String,
}
async fn settings(state: &AppState) -> AppResult<ConnectionsSettings> {
pub async fn settings(state: &AppState) -> AppResult<ConnectionsSettings> {
store::kv_get(state, "connections_settings").await
}
@@ -111,6 +111,11 @@ pub async fn public_config(State(state): State<AppState>) -> AppResult<Json<Valu
}
async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> AppResult<()> {
send_mail(state, to, subject, body, None).await
}
/// Send one message, as plain text and, when given, an HTML version of the same content.
pub async fn send_mail(state: &AppState, to: &str, subject: &str, body: &str, html: Option<&str>) -> AppResult<()> {
let s = settings(state).await?;
if s.resend_api_key.is_empty() || s.sender_email.is_empty() {
return Err(AppError::bad_request("configure Resend SMTP and a sender email in Settings first"));
@@ -119,12 +124,12 @@ async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> Ap
if s.sender_name.is_empty() { s.sender_email.parse() } else { format!("{} <{}>", s.sender_name, s.sender_email).parse() }
.map_err(|_| AppError::bad_request("invalid sender email"))?;
let to: Mailbox = to.parse().map_err(|_| AppError::bad_request("invalid recipient email"))?;
let message = Message::builder()
.from(from)
.to(to)
.subject(subject)
.body(body.to_string())
.map_err(|_| AppError::bad_request("invalid email message"))?;
let builder = Message::builder().from(from).to(to).subject(subject);
let message = match html {
Some(h) => builder.multipart(lettre::message::MultiPart::alternative_plain_html(body.to_string(), h.to_string())),
None => builder.body(body.to_string()),
}
.map_err(|_| AppError::bad_request("invalid email message"))?;
let smtp = AsyncSmtpTransport::<Tokio1Executor>::relay("smtp.resend.com")
.map_err(|e| AppError::bad_request(format!("SMTP configuration failed: {e}")))?
.credentials(Credentials::new("resend".into(), s.resend_api_key))
+28 -15
View File
@@ -11,6 +11,7 @@ use crate::routes::connections::ConnectionsSettings;
use crate::state::AppState;
use crate::store;
use axum::extract::{Path, State};
use crate::embeds;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -336,36 +337,46 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
}
let mut sent = 0;
if s.notify_achievements {
let rows: Vec<(String, String, String)> = sqlx::query_as(
"SELECT COALESCE(u.username, ua.user_uuid), a.title, ua.unlocked_at FROM user_achievements ua
let rows: Vec<(String, String, String, String, i64, String, String)> = sqlx::query_as(
"SELECT ua.user_uuid, COALESCE(u.username, ua.user_uuid), a.title, a.description, a.xp_reward, a.category, ua.unlocked_at FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id LEFT JOIN users u ON u.uuid = ua.user_uuid
WHERE ua.unlocked_at > ? ORDER BY ua.unlocked_at LIMIT 10",
)
.bind(&cursor.achievements)
.fetch_all(&state.db)
.await?;
for (who, title, at) in rows {
post_webhook(state, &s.webhook_url, "Achievement unlocked", &format!("**{}** earned **{}**", clip(&who), clip(&title)), 0xfcd34d).await;
for (uuid, who, title, desc, xp, category, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &who).await;
for (k, v) in [("achievement", embeds::plain(&title)), ("achievement_description", embeds::plain(&desc)), ("xp", xp.to_string()), ("category", embeds::plain(&category))] {
vars.insert(k.into(), v);
}
if embeds::announce_event(state, "achievement", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.achievements = at;
sent += 1;
}
}
if s.notify_guilds {
let rows: Vec<(String, String, String)> =
sqlx::query_as("SELECT name, tag, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
for (name, tag, at) in rows {
post_webhook(state, &s.webhook_url, "New guild", &format!("**{}** [{}] was founded", clip(&name), clip(&tag)), 0x22d3ee).await;
let rows: Vec<(String, String)> = sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
for (id, at) in rows {
let vars = embeds::guild_vars(state, &id).await;
if embeds::announce_event(state, "guild", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.guilds = at;
sent += 1;
}
}
if s.notify_members {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?;
for (name, at) in rows {
post_webhook(state, &s.webhook_url, "New player", &format!("Welcome **{}**!", clip(&name)), 0x6ee7b7).await;
let rows: Vec<(String, String, String)> =
sqlx::query_as("SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?;
for (uuid, name, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &name).await;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
vars.insert("members_total".into(), total.to_string());
if embeds::announce_event(state, "member", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.members = at;
sent += 1;
}
}
store::kv_set(state, "discord_cursor", &cursor).await?;
@@ -385,11 +396,13 @@ pub async fn test_webhook(_: AdminUser, State(state): State<AppState>) -> AppRes
pub fn spawn_worker(state: AppState) {
tokio::spawn(async move {
let mut tick = 0u64;
let mut live_last = std::collections::BTreeMap::new();
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
if let Err(e) = announce(&state).await {
tracing::debug!("discord announce: {e:?}");
}
embeds::tick_live(&state, &mut live_last).await;
tick += 1;
if tick % 30 == 0 {
if let Err(e) = sync_all(&state).await {
+155
View File
@@ -0,0 +1,155 @@
//! Admin editing of Discord message templates and live embeds, with previews built from real data.
use crate::auth::AdminUser;
use crate::embeds::{self, EmbedStyle, LiveConfig};
use crate::error::{AppError, AppResult};
use crate::routes::discord;
use crate::state::AppState;
use crate::store;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;
fn check_style(s: &EmbedStyle) -> AppResult<()> {
let too_long = |t: &str, n: usize| t.chars().count() > n;
if too_long(&s.title, 600) || too_long(&s.description, 4000) || too_long(&s.footer, 600) || too_long(&s.content, 2000) || s.fields.len() > 25 {
return Err(AppError::bad_request("a Discord message part is too long (title 256, text 4096, footer 2048, 25 fields)"));
}
for url in [&s.url, &s.thumbnail, &s.image, &s.avatar_url, &s.author_icon, &s.footer_icon] {
let u = url.trim();
if !u.is_empty() && !u.starts_with('{') && !(u.starts_with("https://") || u.starts_with("http://")) {
return Err(AppError::bad_request("image and link addresses must start with https:// (or be a placeholder like {avatar})"));
}
}
Ok(())
}
pub async fn get_all(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let templates = embeds::templates(&state).await?;
let live = embeds::live_configs(&state).await?;
let mut live_out = serde_json::Map::new();
for (k, c) in &live {
let mut v = serde_json::to_value(c)?;
v["posted"] = json!(!c.message_id.is_empty());
v["webhook_set"] = json!(!c.webhook_url.is_empty());
v["webhook_url"] = json!("");
live_out.insert(k.clone(), v);
}
let placeholders: BTreeMap<&str, Vec<&str>> = embeds::ANNOUNCEMENT_PLACEHOLDERS.iter().map(|(k, v)| (*k, v.to_vec())).collect();
Ok(Json(json!({
"templates": templates,
"live": live_out,
"placeholders": placeholders,
"live_placeholders": {
"status": ["rows", "servers_online", "servers_total", "players_online", "players_max", "server_name", "updated", "time"],
"status_row": ["status_icon", "server", "status", "players", "max", "tps", "version", "last_seen"],
"guilds": ["rows", "guilds_total", "claims_total", "server_name", "updated", "time"],
"guilds_row": ["rank", "guild", "tag", "level", "members", "claims", "leader", "leader_avatar"],
"leaderboard": ["rows", "sort_label", "server_name", "updated", "time"],
"leaderboard_row": ["rank", "player", "avatar", "value", "playtime", "kills", "blocks", "level", "deaths", "mob_kills"],
"baltop": ["rows", "total", "server_name", "updated", "time"],
"baltop_row": ["rank", "player", "avatar", "balance"],
},
"webhook_set": !discord::load(&state).await?.webhook_url.is_empty(),
})))
}
#[derive(Deserialize)]
pub struct TemplatesBody {
templates: BTreeMap<String, EmbedStyle>,
}
pub async fn put_templates(_: AdminUser, State(state): State<AppState>, Json(b): Json<TemplatesBody>) -> AppResult<Json<Value>> {
let known = embeds::default_templates();
let mut next = BTreeMap::new();
for (k, v) in b.templates {
if !known.contains_key(&k) {
return Err(AppError::bad_request("unknown message"));
}
check_style(&v)?;
next.insert(k, v);
}
store::kv_set(&state, "discord_templates", &next).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct LiveBody {
live: BTreeMap<String, LiveConfig>,
}
pub async fn put_live(_: AdminUser, State(state): State<AppState>, Json(b): Json<LiveBody>) -> AppResult<Json<Value>> {
let mut all = embeds::live_configs(&state).await?;
for (k, mut v) in b.live {
let Some(old) = all.get(&k) else { return Err(AppError::bad_request("unknown embed")) };
check_style(&v.style)?;
let hook = v.webhook_url.trim().to_string();
v.webhook_url = match hook.as_str() {
"" => old.webhook_url.clone(), // blank keeps the saved one; "-" clears it
"-" => String::new(),
h if discord::valid_webhook(h) => h.to_string(),
_ => return Err(AppError::bad_request("that is not a Discord webhook address")),
};
v.interval_secs = v.interval_secs.clamp(60, 86_400);
v.limit = v.limit.clamp(1, 25);
if !["playtime", "level", "kills", "blocks"].contains(&v.sort.as_str()) {
v.sort = "playtime".into();
}
v.message_id = old.message_id.clone();
all.insert(k, v);
}
embeds::save_live(&state, &all).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct PreviewBody {
/// `achievement`, `member`, `guild` or a live kind
kind: String,
style: Option<EmbedStyle>,
live: Option<LiveConfig>,
}
/// What the message would look like right now, as the exact JSON Discord receives.
pub async fn preview(_: AdminUser, State(state): State<AppState>, Json(b): Json<PreviewBody>) -> AppResult<Json<Value>> {
if embeds::LIVE_KINDS.contains(&b.kind.as_str()) {
let cfg = b.live.ok_or_else(|| AppError::bad_request("missing embed settings"))?;
check_style(&cfg.style)?;
return Ok(Json(embeds::build_live(&state, &b.kind, &cfg).await?));
}
let style = b.style.ok_or_else(|| AppError::bad_request("missing message settings"))?;
check_style(&style)?;
if !embeds::default_templates().contains_key(&b.kind) {
return Err(AppError::bad_request("unknown message"));
}
let vars = embeds::sample_vars(&state, &b.kind).await;
Ok(Json(embeds::render(&style, &vars, None)))
}
/// Send the saved message to Discord now (a sample announcement, or a live embed refresh).
pub async fn send_now(_: AdminUser, State(state): State<AppState>, Path(kind): Path<String>) -> AppResult<Json<Value>> {
if embeds::LIVE_KINDS.contains(&kind.as_str()) {
embeds::refresh_live(&state, &kind, false).await?;
return Ok(Json(json!({ "ok": true })));
}
let templates = embeds::templates(&state).await?;
let style = templates.get(&kind).ok_or_else(|| AppError::bad_request("unknown message"))?;
let s = discord::load(&state).await?;
if s.webhook_url.is_empty() {
return Err(AppError::bad_request("save a webhook first"));
}
let vars = embeds::sample_vars(&state, &kind).await;
embeds::post(&state, &s.webhook_url, &embeds::render(style, &vars, None)).await?;
Ok(Json(json!({ "ok": true })))
}
/// Forget the posted message so the next update creates a new one (use after moving the embed to another channel).
pub async fn repost(_: AdminUser, State(state): State<AppState>, Path(kind): Path<String>) -> AppResult<Json<Value>> {
if !embeds::LIVE_KINDS.contains(&kind.as_str()) {
return Err(AppError::bad_request("unknown embed"));
}
embeds::refresh_live(&state, &kind, true).await?;
Ok(Json(json!({ "ok": true })))
}
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more