Fabric 1.20.1: server features (commands, LuckPerms, Placeholder API, economy API, client link) and client mod (HUD, editor, shop and market windows); Paper client link; docs and examples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 19:38:18 +00:00
1 parent 84c9d46892
commit 2bcdf26351
43 files changed
+2112 -42

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
# Fabric: server mod features and the client mod
Two jars, both for **Fabric 1.20.1**:
| Jar | Install on | Needs |
|---|---|---|
| `scopenet-fabric-1.20.1.jar` (server mod) | the server (and a single-player world if you want the commands there) | Fabric API. Optional: LuckPerms, Placeholder API |
| `scopenet-client-fabric-1.20.1.jar` (client mod) | players who want the HUD, claim borders and shop/market windows | Fabric API |
The client mod is optional and display-only. The server stays in charge of claims, money and permissions; every button in the client is an ordinary command. A player without it loses nothing. You can install both jars on the same machine (for example a single-player world), because the client mod shares no code with the server mod.
Other Minecraft versions keep the original behaviour (sign-in, stats, claims, Live Map) and do not get the commands yet.
## Build
```sh
gradle -p integrations -Ploader=fabric -PmcVersion=1.20.1 :fabric:build # server mod
gradle -p integrations -Ploader=fabric-client :fabric-client:build # client mod
```
The core logic (`integrations/common`, package `net.scopenet.core`) is platform-neutral and unit-tested with `gradle -p integrations :common:test`. The Fabric classes are thin adapters over it.
## What the server mod adds (same behaviour as the Paper plugin)
* **Essentials:** `/spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime` (homes and warps saved in `config/scopenet/essentials.json`). `/back` remembers where you were before SCOPENET teleports and where you died.
* **Economy:** `/balance /pay /baltop /shop /sell hand /market (sell|buy) /orders /transactions`. Selling and buying go through an escrow file (`economy-pending.json`) so a crash or a panel outage never loses an item or a payment.
* **Guilds:** `/guild` (info, create, leave, claim, unclaim, map, chat, sethome, home, members, bank, pay, sell, market), `/claim`, `/unclaim`. Claim checks use the same local index as the rest of the mod.
* **Admin:** `/scopenet help | status | reload` (alias `/sn`).
* **Permissions:** the same `scopenet.command.*` nodes as Paper. With the LuckPerms mod installed its answer wins; otherwise `permissions.default_level` decides, and admin nodes need operator level 2. `scopenet.claims.bypass` lets staff build in other guilds' claims.
* **Placeholder API:** `%scopenet:level%`, `%scopenet:guild%`, `%scopenet:balance%`, `%scopenet:playtime%`, `%scopenet:quest_progress%` and the rest of the Paper list (see `Placeholders.KEYS`).
* **Economy for other mods:** `net.scopenet.fabric.api.ScopenetEconomy` (Fabric's stand-in for Vault): `balance`, `deposit`, `withdraw`, all asynchronous and safe to retry.
Not on Fabric yet: `/trade` (needs a chest window), item sorting of homes/warps into a menu (they are listed in chat), and teleports by other mods are not remembered for `/back`.
### Economy mods
SCOPENET keeps the ledger in the panel, so its money is one number per player across your network. Other Fabric mods can use it through `ScopenetEconomy`. Adapters that register SCOPENET as a provider inside third-party economy mods (Impactor, Common Economy API and similar) are **not included**: their APIs change between versions and I could not build against them, so shipping guesses would risk silent money bugs. They are small to add once the exact versions are chosen; the extension point is `ScopenetEconomy`.
## The client mod
* **HUD widgets:** Level (with XP bar and server level), Balance, Guild, Claim (who owns the chunk you are in) and Quests. Each can be on, compact or off.
* **Claim borders:** green boxes around your guild's land and red around other guilds', within 4 chunks.
* **Windows:** press **K** for the menu, then **Shop** (every price, your held item and what it is worth, one-click sell), **Market** (browse, one-click buy, list your held item) or **Settings**.
* **HUD layout editor:** Settings → *Edit HUD layout*. Drag widgets, right-click to hide or show, scroll to resize. Positions snap to screen anchors, so they hold on any window size.
* **Toasts:** level-ups, achievements and guild changes, each switchable.
* **Keys:** *Open SCOPENET menu* (K) and *Show or hide SCOPENET HUD* (unbound) in Controls → SCOPENET.
Settings are saved to `config/scopenet-client.json` (see `examples/fabric-client/`).
### How it talks to the server
Two plugin-message channels, `scopenet:s2c` and `scopenet:c2s`, carrying JSON in a Minecraft UTF string (`net.scopenet.core.Wire`). The client says hello; the server then sends its state, the claims around the player, and answers market and shop requests. The same channels work on the Paper plugin (`clientlink.enabled` in `config.yml`), so the client mod works on both. Turn the link off with `clientlink.enabled=false`.
## Status
The shared core, the escrow flow, the client-link protocol and the placeholders are covered by unit tests that run in CI (`CommandsTest`, `LinkTest`, `EssentialsServiceTest`). **The Fabric server mod, the client mod and the Paper additions have not been compiled or run**, because the build environment they were written in cannot download Minecraft, Fabric or Paper. Build them, then report any compile errors.
+2 -2
View File
@@ -8,7 +8,7 @@ Use **online-mode=true**. The server's authlib-injector agent redirects Minecraf
2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token. 2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token.
3. Install one server integration from Releases: 3. Install one server integration from Releases:
- Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported. - Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported.
- Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. No Fabric API or client mod required. - Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. Sign-in, stats, claims and the Live Map need no Fabric API or client mod. The 1.20.1 build adds commands, permissions, placeholders and the client link, and needs Fabric API; see [fabric.md](fabric.md).
- Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET. - Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET.
- Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds. - Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds.
4. Configure Paper at `plugins/SCOPENET/config.yml`: 4. Configure Paper at `plugins/SCOPENET/config.yml`:
@@ -71,6 +71,6 @@ gradle -p integrations -Ploader=forge -PmcVersion=1.21.1 :forge:build
gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build
``` ```
Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The locally built 0.4.0 matrix for 1.20.1, 1.21.1, and 26.3 plus Paper is in [`release-artifacts/0.4.0`](../release-artifacts/0.4.0/README.md), with SHA-256 checksums. The **Server integrations** workflow can rebuild the matrix when Actions usage is available again. The optional Fabric client mod builds with `-Ploader=fabric-client :fabric-client:build`. Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The locally built 0.4.0 matrix for 1.20.1, 1.21.1, and 26.3 plus Paper is in [`release-artifacts/0.4.0`](../release-artifacts/0.4.0/README.md), with SHA-256 checksums. The **Server integrations** workflow can rebuild the matrix when Actions usage is available again.
Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack. Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack.
+12 -15
View File
@@ -1,27 +1,24 @@
{ {
"_about": "SCOPENET client mod (Fabric 1.20.1) -> config/scopenet-client.json. The in-game config screen edits this file; you rarely need to change it by hand. The mod is in development and this format may grow.",
"enabled": true, "enabled": true,
"hud": { "hud": {
"scale": 1.0, "scale": 1.0,
"opacity": 0.85, "opacity": 0.85,
"widgets": {
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "showXpBar": true },
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -8, "showClaimOwner": true },
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "maxShown": 3 },
"claimBorders": { "enabled": true, "showOnlyWhenSneaking": false }
},
"hideInF3": true, "hideInF3": true,
"hideWhileChatOpen": false "hideWhileChatOpen": false,
"claimBorders": true,
"claimBordersOnlyWhenSneaking": false,
"widgets": {
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "compact": false },
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -40, "compact": false },
"claim": { "enabled": true, "anchor": "top_center", "x": 0, "y": 8, "compact": false },
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "compact": false }
}
}, },
"notifications": { "notifications": {
"levelUp": true, "levelUp": true,
"achievements": true, "achievements": true,
"friendRequests": true, "guild": true,
"gameInvites": true "errors": true
},
"keybinds": {
"openMenu": "key.keyboard.k",
"toggleHud": "key.keyboard.h"
} }
} }
+21 -19
View File
@@ -1,6 +1,7 @@
# SCOPENET for Fabric (server side) -> config/scopenet.properties # SCOPENET for Fabric (server side) -> config/scopenet.properties
# Works with the same panel as the Paper plugin. Keys marked [planned] are read once the Fabric # Same panel as the Paper plugin. Sign-in, stats, claims and the Live Map work on every supported Fabric version.
# feature-parity work lands; unknown keys are ignored, so it is safe to keep them here. # Commands, permissions, placeholders and the client link need Fabric API and are in the Fabric 1.20.1 build.
# Extra files the mod creates: config/scopenet/essentials.json (homes, warps), guild-homes.json, economy-pending.json.
# --- Connection (required) --------------------------------------------------- # --- Connection (required) ---------------------------------------------------
# Your panel address and the token from Servers -> this server in the panel. # Your panel address and the token from Servers -> this server in the panel.
@@ -24,29 +25,30 @@ economy.enabled=true
# (also needs Live Map switched on for this server in the panel). # (also needs Live Map switched on for this server in the panel).
livemap.enabled=true livemap.enabled=true
# --- Essentials [planned] ---------------------------------------------------- # --- Essentials (1.20.1) -----------------------------------------------------
# Homes, warps, /back, /tpa and /rtp. Same defaults as Paper. # /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
essentials.max_homes=5 essentials.max_homes=5
essentials.rtp_radius=2500 essentials.rtp_radius=2500
essentials.tpa_timeout_seconds=60 essentials.tpa_timeout_seconds=60
# --- Permissions [planned] --------------------------------------------------- # --- Economy (1.20.1) --------------------------------------------------------
# Uses the LuckPerms mod when it is installed (nodes are scopenet.command.*, same as Paper). # Shown next to amounts.
# Without it, these defaults apply: "all" lets every player use the command, "op" limits to operators.
permissions.default_level=all
permissions.admin_commands=op
# --- Economy mods [planned] --------------------------------------------------
# auto : use the first supported economy mod found (Impactor, Common Economy API), else SCOPENET's own balances
# scopenet: always use SCOPENET balances (other mods can still read them through the SCOPENET API)
economy.provider=auto
economy.currency_symbol=$ economy.currency_symbol=$
# --- Placeholder API [planned] ----------------------------------------------- # --- Permissions (1.20.1) ----------------------------------------------------
# Registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%, %scopenet:playtime%, # With the LuckPerms mod installed, its answer wins. Use the same nodes as the Paper plugin
# %scopenet:quest_progress% when Placeholder API (Fabric) is installed. # (scopenet.command.home, scopenet.command.guild.bank.withdraw, scopenet.claims.bypass ...).
# Without LuckPerms, or when LuckPerms has no setting for a node:
# all : every player may use ordinary commands
# op : only operators may
# Admin nodes (/scopenet status and reload, scopenet.claims.bypass) always need operator level 2 by default.
permissions.default_level=all
# --- Placeholder API (1.20.1) ------------------------------------------------
# With Placeholder API installed, registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%,
# %scopenet:playtime%, %scopenet:quest_progress% and the rest of the Paper placeholder list.
placeholders.enabled=true placeholders.enabled=true
# --- Client mod link [planned] ----------------------------------------------- # --- Client mod link (1.20.1) ------------------------------------------------
# Send balance, level and guild info to players who have the SCOPENET client mod. # Send balance, level, guild and claim info to players who have the SCOPENET client mod.
clientlink.enabled=true clientlink.enabled=true
+9
View File
@@ -114,6 +114,15 @@ integrations:
enabled: true enabled: true
report-interval-seconds: 30 report-interval-seconds: 30
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
# are unaffected. Display only: everything the client does is an ordinary command.
# ==============================================================================
clientlink:
enabled: true
# ============================================================================== # ==============================================================================
# Permissions (set these in LuckPerms, not here) # Permissions (set these in LuckPerms, not here)
# scopenet.command.<command> e.g. scopenet.command.home # scopenet.command.<command> e.g. scopenet.command.home
@@ -194,6 +194,36 @@ public final class ClientLink {
send(p, m); send(p, m);
} }
/**
* Events the panel queued for this server (level-ups, achievements, guild changes). Safe to call from any thread:
* each becomes a toast on the player's client and forces a fresh state.
*/
public void onPanelEvents(JsonArray events) {
for (JsonElement element : events) {
if (!element.isJsonObject()) continue;
JsonObject n = element.getAsJsonObject();
String kind = PlayerCache.str(n, "kind", "");
String uuidText = PlayerCache.str(n, "uuid", "");
JsonObject d = PlayerCache.obj(n, "data");
UUID uuid;
try { uuid = UUID.fromString(uuidText); } catch (IllegalArgumentException e) { continue; }
String title, text;
switch (kind) {
case "level_up" -> { title = "Level up!"; text = ("server".equals(PlayerCache.str(d, "scope", "")) ? "Server level " : "Level ") + (long) PlayerCache.num(d, "level", 0); }
case "achievement" -> { title = "Achievement unlocked"; text = PlayerCache.str(d, "title", ""); }
case "guild_join" -> { title = "Guild"; text = "Joined " + PlayerCache.str(d, "guild", "a guild"); }
case "guild_leave" -> { title = "Guild"; text = "Left " + PlayerCache.str(d, "guild", "your guild"); }
default -> { continue; }
}
final String t = title, x = text, k = kind;
env.platform.runMain(() -> env.platform.player(uuid).ifPresent(p -> {
notify(p, k, t, x);
lastState.remove(uuid);
cache.refresh(p, info -> pushState(p));
}));
}
}
/** About once a second: new chunk, new claims, or new state for every client. */ /** About once a second: new chunk, new claims, or new state for every client. */
public void tick() { public void tick() {
for (UUID id : new ArrayList<>(clients)) { for (UUID id : new ArrayList<>(clients)) {
@@ -114,6 +114,17 @@ class LinkTest {
assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble()); assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble());
} }
@Test void panelEventsBecomeToasts() {
link.receive(alex, msg("{\"t\":\"hello\"}"));
link.onPanelEvents(JsonParser.parseString("[{\"kind\":\"level_up\",\"uuid\":\"" + alex.id + "\",\"data\":{\"scope\":\"global\",\"level\":13}},"
+ "{\"kind\":\"achievement\",\"uuid\":\"" + alex.id + "\",\"data\":{\"title\":\"Deep Diver\"}},"
+ "{\"kind\":\"unknown\",\"uuid\":\"" + alex.id + "\",\"data\":{}},{\"kind\":\"level_up\",\"uuid\":\"nope\"}]").getAsJsonArray());
List<JsonObject> toasts = sent.stream().filter(m -> m.get("t").getAsString().equals("notify")).toList();
assertEquals(2, toasts.size());
assertEquals("Level 13", toasts.get(0).get("text").getAsString());
assertEquals("Deep Diver", toasts.get(1).get("text").getAsString());
}
@Test void disabledClientLinkIsSilent() { @Test void disabledClientLinkIsSilent() {
kit.env.features = new Features(true, true, true, true, true, false, "$"); kit.env.features = new Features(true, true, true, true, true, false, "$");
link.receive(alex, msg("{\"t\":\"hello\"}")); link.receive(alex, msg("{\"t\":\"hello\"}"));
+16
View File
@@ -0,0 +1,16 @@
plugins { id 'fabric-loom' version '1.8.13' }
// Client-only companion to the SCOPENET plugin / Fabric server mod. Fabric 1.20.1.
// It needs nothing from `common`: the messages are plain JSON inside a Minecraft UTF string.
java.sourceCompatibility = JavaVersion.VERSION_17
java.targetCompatibility = JavaVersion.VERSION_17
dependencies {
minecraft 'com.mojang:minecraft:1.20.1'
mappings loom.officialMojangMappings()
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand(version: project.version) }
}
base.archivesName = 'scopenet-client-fabric-1.20.1'
@@ -0,0 +1,105 @@
package net.scopenet.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
/** config/scopenet-client.json: what to show and where. Edited in game (K, then Settings) or by hand. */
public final class ClientConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path FILE = FabricLoader.getInstance().getConfigDir().resolve("scopenet-client.json");
/** Where a widget sits. {@code x}/{@code y} are offsets from the chosen screen anchor. */
public static final class WidgetConfig {
public boolean enabled = true;
public String anchor = "top_left";
public int x = 8;
public int y = 8;
public boolean compact = false;
WidgetConfig(boolean enabled, String anchor, int x, int y) { this.enabled = enabled; this.anchor = anchor; this.x = x; this.y = y; }
public WidgetConfig() { }
}
public static final class Hud {
public float scale = 1.0f;
public float opacity = 0.85f;
public boolean hideInF3 = true;
public boolean hideWhileChatOpen = false;
public boolean claimBorders = true;
public boolean claimBordersOnlyWhenSneaking = false;
public Map<String, WidgetConfig> widgets = defaults();
}
public static final class Notifications {
public boolean levelUp = true;
public boolean achievements = true;
public boolean guild = true;
public boolean errors = true;
}
public boolean enabled = true;
public Hud hud = new Hud();
public Notifications notifications = new Notifications();
public static Map<String, WidgetConfig> defaults() {
Map<String, WidgetConfig> m = new LinkedHashMap<>();
m.put("level", new WidgetConfig(true, "top_left", 8, 8));
m.put("balance", new WidgetConfig(true, "top_right", -8, 8));
m.put("guild", new WidgetConfig(true, "bottom_left", 8, -40));
m.put("claim", new WidgetConfig(true, "top_center", 0, 8));
m.put("quests", new WidgetConfig(false, "middle_right", -8, 0));
return m;
}
public WidgetConfig widget(String id) { return hud.widgets.computeIfAbsent(id, k -> defaults().getOrDefault(k, new WidgetConfig())); }
public static ClientConfig load() {
ClientConfig config = new ClientConfig();
if (Files.exists(FILE)) {
try (Reader in = Files.newBufferedReader(FILE)) {
JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
ClientConfig read = GSON.fromJson(root, ClientConfig.class);
if (read != null) config = read;
} catch (IOException | RuntimeException e) {
System.err.println("[SCOPENET] Could not read " + FILE + ", using defaults: " + e.getMessage());
}
}
// Keep every widget present, and values sane.
Map<String, WidgetConfig> merged = defaults();
if (config.hud == null) config.hud = new Hud();
if (config.hud.widgets != null) merged.putAll(config.hud.widgets);
config.hud.widgets = merged;
if (config.notifications == null) config.notifications = new Notifications();
config.hud.scale = Math.max(0.5f, Math.min(2.0f, config.hud.scale));
config.hud.opacity = Math.max(0.1f, Math.min(1.0f, config.hud.opacity));
return config;
}
public void save() {
try {
Path tmp = FILE.resolveSibling(FILE.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(this, w); }
Files.move(tmp, FILE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
System.err.println("[SCOPENET] Could not save " + FILE + ": " + e.getMessage());
}
}
public void resetHud() {
hud.widgets = defaults();
hud.scale = 1.0f;
hud.opacity = 0.85f;
}
}
@@ -0,0 +1,124 @@
package net.scopenet.client;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.List;
/** What the server last told this client. Everything is optional: before the first message the HUD shows nothing. */
public final class ClientState {
public record Quest(int done, int total) {}
public record Guild(String name, String tag, String role, long claims) {}
public record Owner(String name, String tag, boolean mine) {}
public record Listing(long id, String seller, String guildTag, String itemId, String itemName, int amount, double price) {}
public record ShopItem(String id, String name, double price) {}
public record Held(String id, String name, int count, double unit) {}
public volatile boolean connected;
public volatile String server = "";
public volatile String currency = "$";
public volatile boolean featEconomy, featGuilds, featClaims, featEssentials;
public volatile boolean hasState;
public volatile String name = "";
public volatile long level = 1, xp, xpNow, xpNext, serverLevel = 1;
public volatile double xpPct, serverXpPct, balance;
public volatile String title = "", rank = "";
public volatile Guild guild;
public volatile Quest daily = new Quest(0, 0), weekly = new Quest(0, 0);
public volatile long playtime, kills, deaths;
public volatile String claimDim = "";
public volatile int claimCx, claimCz, claimRadius;
public volatile String claimCells = "";
public volatile Owner claimOwner;
public volatile List<Listing> market = List.of();
public volatile List<ShopItem> shop = List.of();
public volatile Held held;
public volatile long marketAt, shopAt;
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
public String[] handle(String json) {
JsonObject m;
try { m = JsonParser.parseString(json).getAsJsonObject(); } catch (RuntimeException e) { return null; }
switch (str(m, "t")) {
case "hello" -> {
connected = true;
server = str(m, "server");
currency = m.has("currency") ? str(m, "currency") : "$";
JsonObject f = obj(m, "features");
featEconomy = bool(f, "economy"); featGuilds = bool(f, "guilds"); featClaims = bool(f, "claims"); featEssentials = bool(f, "essentials");
}
case "state" -> {
name = str(m, "name");
level = lng(m, "level", 1); xp = lng(m, "xp", 0); xpNow = lng(m, "xpNow", 0); xpNext = lng(m, "xpNext", 0);
xpPct = num(m, "xpPct"); title = str(m, "title"); serverLevel = lng(m, "serverLevel", 1); serverXpPct = num(m, "serverXpPct");
balance = num(m, "balance"); playtime = lng(m, "playtime", 0); kills = lng(m, "kills", 0); deaths = lng(m, "deaths", 0);
rank = str(m, "rank");
JsonObject g = obj(m, "guild");
guild = g == null ? null : new Guild(str(g, "name"), str(g, "tag"), str(g, "role"), lng(g, "claims", 0));
daily = quest(obj(m, "daily"));
weekly = quest(obj(m, "weekly"));
hasState = true;
}
case "claims" -> {
claimDim = str(m, "dim"); claimCx = (int) lng(m, "cx", 0); claimCz = (int) lng(m, "cz", 0);
claimRadius = (int) lng(m, "r", 0); claimCells = str(m, "cells");
JsonObject o = obj(m, "owner");
claimOwner = o == null ? null : new Owner(str(o, "name"), str(o, "tag"), bool(o, "mine"));
}
case "market" -> {
List<Listing> list = new ArrayList<>();
for (JsonElement e : arr(m, "listings")) {
JsonObject l = e.getAsJsonObject();
list.add(new Listing(lng(l, "id", 0), str(l, "seller_name"), l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? str(l, "seller_guild") : null,
str(l, "item_id"), str(l, "item_name"), (int) lng(l, "amount", 1), num(l, "price")));
}
market = list;
if (m.has("balance")) balance = num(m, "balance");
marketAt = System.currentTimeMillis();
}
case "shop" -> {
List<ShopItem> list = new ArrayList<>();
for (JsonElement e : arr(m, "items")) {
JsonObject i = e.getAsJsonObject();
list.add(new ShopItem(str(i, "id"), str(i, "name"), num(i, "price")));
}
shop = list;
JsonObject h = obj(m, "held");
held = h == null ? null : new Held(str(h, "id"), str(h, "name"), (int) lng(h, "count", 0), h.has("unit") ? num(h, "unit") : -1);
shopAt = System.currentTimeMillis();
}
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
default -> { }
}
return null;
}
/** Sample values so the layout editor can show every widget even with no server data. */
public static ClientState preview() {
ClientState s = new ClientState();
s.connected = true; s.hasState = true; s.featEconomy = true; s.featGuilds = true; s.featClaims = true; s.featEssentials = true;
s.level = 27; s.xpPct = 62; s.title = "Miner"; s.serverLevel = 9; s.balance = 12840.5;
s.guild = new Guild("Iron Wolves", "IRON", "officer", 14);
s.daily = new Quest(2, 3); s.weekly = new Quest(1, 4);
s.claimCells = "x"; s.claimOwner = new Owner("Iron Wolves", "IRON", true);
return s;
}
public void reset() {
connected = false; hasState = false; guild = null; claimOwner = null; claimCells = ""; market = List.of(); shop = List.of(); held = null;
}
private static Quest quest(JsonObject o) { return o == null ? new Quest(0, 0) : new Quest((int) lng(o, "done", 0), (int) lng(o, "total", 0)); }
private static String str(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() ? e.getAsString() : ""; }
private static double num(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : 0; }
private static long lng(JsonObject o, String k, long d) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : d; }
private static boolean bool(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsBoolean(); }
private static JsonObject obj(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonObject() ? e.getAsJsonObject() : null; }
private static JsonArray arr(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); }
}
@@ -0,0 +1,59 @@
package net.scopenet.client;
import com.google.gson.JsonObject;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.client.gui.components.toasts.SystemToast;
import net.minecraft.resources.ResourceLocation;
/** The channel to the SCOPENET server mod or plugin. Messages are JSON in a Minecraft UTF string. */
public final class Link {
private static final ResourceLocation TO_CLIENT = new ResourceLocation("scopenet", "s2c");
private static final ResourceLocation TO_SERVER = new ResourceLocation("scopenet", "c2s");
private final ClientState state;
private final ClientConfig config;
Link(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
void register() {
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
String json = buf.readUtf(32767 * 3);
client.execute(() -> toast(state.handle(json)));
});
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
}
public void send(String type) {
JsonObject m = new JsonObject();
m.addProperty("t", type);
if (type.equals("hello")) m.addProperty("mod", "0.1.0");
FriendlyByteBuf buf = PacketByteBufs.create();
buf.writeUtf(m.toString(), 32767 * 3);
try { ClientPlayNetworking.send(TO_SERVER, buf); } catch (RuntimeException ignored) { /* not in a world */ }
}
/** Run a server command as the player, exactly as if typed. */
public static void command(String command) {
Minecraft mc = Minecraft.getInstance();
if (mc.player != null) mc.player.connection.sendCommand(command.startsWith("/") ? command.substring(1) : command);
}
private void toast(String[] t) {
if (t == null) return;
boolean wanted = switch (t[0]) {
case "level_up" -> config.notifications.levelUp;
case "achievement" -> config.notifications.achievements;
case "guild_join", "guild_leave" -> config.notifications.guild;
case "error" -> config.notifications.errors;
default -> true;
};
if (!wanted || !config.enabled) return;
SystemToast.add(Minecraft.getInstance().getToasts(), SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, Component.literal(t[1]), Component.literal(t[2]));
}
}
@@ -0,0 +1,47 @@
package net.scopenet.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
import net.minecraft.client.KeyMapping;
import net.scopenet.client.hud.ClaimBorders;
import net.scopenet.client.hud.Hud;
import net.scopenet.client.screen.MenuScreen;
import org.lwjgl.glfw.GLFW;
/**
* SCOPENET client: HUD widgets, claim borders, and the shop and market windows. Everything it shows comes from the
* server over the {@code scopenet:s2c} channel, and everything it does is an ordinary command, so the server stays
* in charge. If the server has no SCOPENET, this does nothing.
*/
public final class ScopenetClient implements ClientModInitializer {
private static ClientConfig config;
private static ClientState state;
private static Link link;
public static ClientConfig config() { return config; }
public static ClientState state() { return state; }
public static Link link() { return link; }
@Override public void onInitializeClient() {
config = ClientConfig.load();
state = new ClientState();
link = new Link(state, config);
link.register();
Hud hud = new Hud(state, config);
HudRenderCallback.EVENT.register((graphics, tickDelta) -> hud.render(graphics, tickDelta));
ClaimBorders borders = new ClaimBorders(state, config);
WorldRenderEvents.LAST.register(borders::render);
KeyMapping menu = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.menu", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.scopenet"));
KeyMapping toggle = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.toggle_hud", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, "key.categories.scopenet"));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (menu.consumeClick()) if (client.screen == null && client.player != null) client.setScreen(new MenuScreen());
while (toggle.consumeClick()) { config.enabled = !config.enabled; config.save(); }
});
}
}
@@ -0,0 +1,58 @@
package net.scopenet.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import java.util.Locale;
/** Shared look: the SCOPENET palette and a few drawing helpers, so every widget and window feels like one app. */
public final class Ui {
public static final int ACCENT = 0xFF8B6CFF, CYAN = 0xFF22D3EE, GOLD = 0xFFFBBF24, GREEN = 0xFF34D399, RED = 0xFFFB7185;
public static final int TEXT = 0xFFE6E8EE, MUTED = 0xFF9AA0AE, PANEL = 0x0E1014, PANEL_2 = 0x1A1D25, LINE = 0xFF2B2F3A;
private Ui() {}
public static int alpha(int rgb, float opacity) {
return (Math.max(0, Math.min(255, (int) (opacity * 255))) << 24) | (rgb & 0xFFFFFF);
}
public static String money(String symbol, double value) { return symbol + String.format(Locale.US, "%,.2f", value); }
/** A card: dark rounded-looking panel with a coloured edge on the left. */
public static void card(GuiGraphics g, int x, int y, int w, int h, int accent, float opacity) {
g.fill(x + 1, y, x + w - 1, y + h, alpha(PANEL, opacity));
g.fill(x, y + 1, x + w, y + h - 1, alpha(PANEL, opacity));
g.fill(x, y + 1, x + 2, y + h - 1, accent);
}
/** A flat panel with a 1px border, for windows. */
public static void window(GuiGraphics g, int x, int y, int w, int h) {
g.fill(x - 1, y - 1, x + w + 1, y + h + 1, LINE);
g.fill(x, y, x + w, y + h, 0xF0101319);
}
public static void bar(GuiGraphics g, int x, int y, int w, int h, double pct, int colour, float opacity) {
g.fill(x, y, x + w, y + h, alpha(0x2B2F3A, Math.max(opacity, 0.6f)));
int filled = (int) Math.round(w * Math.max(0, Math.min(100, pct)) / 100.0);
if (filled > 0) g.fill(x, y, x + filled, y + h, colour);
}
/** The item for a panel id ("DIAMOND" or "modid:item"); barrier if the client doesn't know it. */
public static net.minecraft.world.item.ItemStack stackFor(String panelId, int count) {
String registry = panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(Locale.ROOT);
net.minecraft.resources.ResourceLocation id = net.minecraft.resources.ResourceLocation.tryParse(registry);
net.minecraft.world.item.Item item = id == null ? net.minecraft.world.item.Items.AIR : net.minecraft.core.registries.BuiltInRegistries.ITEM.get(id);
if (item == net.minecraft.world.item.Items.AIR) item = net.minecraft.world.item.Items.BARRIER;
return new net.minecraft.world.item.ItemStack(item, Math.max(1, count));
}
public static boolean inside(double mx, double my, int x, int y, int w, int h) { return mx >= x && mx < x + w && my >= y && my < y + h; }
public static String clip(Font font, String text, int maxWidth) {
if (font.width(text) <= maxWidth) return text;
String ellipsis = "…";
int end = text.length();
while (end > 0 && font.width(text.substring(0, end) + ellipsis) > maxWidth) end--;
return text.substring(0, end) + ellipsis;
}
}
@@ -0,0 +1,49 @@
package net.scopenet.client.hud;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.world.phys.Vec3;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** Draws the borders of claimed chunks around you in the world: green for your guild's land, red for anyone else's. */
public final class ClaimBorders {
private final ClientState state;
private final ClientConfig config;
public ClaimBorders(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
public void render(WorldRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !config.hud.claimBorders || !state.connected || !state.featClaims || state.claimCells.isEmpty() || mc.player == null) return;
if (config.hud.claimBordersOnlyWhenSneaking && !mc.player.isShiftKeyDown()) return;
if (context.consumers() == null) return;
// Only the world the server sent the map for.
String dim = mc.level == null ? "" : mc.level.dimension().location().toString();
if (!dim.equals(state.claimDim)) return;
int r = state.claimRadius, side = r * 2 + 1;
String cells = state.claimCells;
if (cells.length() != side * side) return;
Vec3 cam = context.camera().getPosition();
PoseStack pose = context.matrixStack();
VertexConsumer lines = context.consumers().getBuffer(RenderType.lines());
double y0 = Math.floor(mc.player.getY()) - 6, y1 = y0 + 14;
pose.pushPose();
pose.translate(-cam.x, -cam.y, -cam.z);
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
char cell = cells.charAt((dz + r) * side + (dx + r));
if (cell == '0') continue;
float cr = cell == '1' ? 0.2f : 0.98f, cg = cell == '1' ? 0.85f : 0.35f, cb = cell == '1' ? 0.55f : 0.45f;
double x0 = (state.claimCx + dx) * 16.0, z0 = (state.claimCz + dz) * 16.0;
LevelRenderer.renderLineBox(pose, lines, x0 + 0.02, y0, z0 + 0.02, x0 + 15.98, y1, z0 + 15.98, cr, cg, cb, 0.9f);
}
}
pose.popPose();
}
}
@@ -0,0 +1,60 @@
package net.scopenet.client.hud;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.ChatScreen;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
/** Lays out and draws the widgets. The editor uses the same maths, so what you place is what you get. */
public final class Hud {
public static final String[] ANCHORS = {"top_left", "top_center", "top_right", "middle_left", "middle_center", "middle_right", "bottom_left", "bottom_center", "bottom_right"};
private final ClientState state;
private final ClientConfig config;
public Hud(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
/** Top-left corner for a widget of size (w, h) on a screen of (sw, sh) (in HUD units, i.e. already divided by the scale). */
public static int[] place(WidgetConfig c, int sw, int sh, int w, int h) {
String a = c.anchor == null ? "top_left" : c.anchor;
int x = a.endsWith("right") ? sw - w : a.endsWith("center") ? (sw - w) / 2 : 0;
int y = a.startsWith("bottom") ? sh - h : a.startsWith("middle") ? (sh - h) / 2 : 0;
return new int[]{x + c.x, y + c.y};
}
/** The anchor nearest to a spot, and the offsets from it, so dragged widgets keep their place on any window size. */
public static void anchorTo(WidgetConfig c, int sw, int sh, int w, int h, int left, int top) {
double cx = left + w / 2.0, cy = top + h / 2.0;
String col = cx < sw / 3.0 ? "left" : cx > sw * 2 / 3.0 ? "right" : "center";
String row = cy < sh / 3.0 ? "top" : cy > sh * 2 / 3.0 ? "bottom" : "middle";
String anchor = row + "_" + col;
c.anchor = anchor;
int bx = anchor.endsWith("right") ? sw - w : anchor.endsWith("center") ? (sw - w) / 2 : 0;
int by = anchor.startsWith("bottom") ? sh - h : anchor.startsWith("middle") ? (sh - h) / 2 : 0;
c.x = left - bx;
c.y = top - by;
}
public void render(GuiGraphics g, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !state.connected || mc.options.hideGui || mc.player == null) return;
if (config.hud.hideInF3 && mc.options.renderDebug) return;
if (config.hud.hideWhileChatOpen && mc.screen instanceof ChatScreen) return;
Font font = mc.font;
float scale = config.hud.scale;
int sw = (int) (g.guiWidth() / scale), sh = (int) (g.guiHeight() / scale);
g.pose().pushPose();
g.pose().scale(scale, scale, 1f);
for (HudWidget widget : Widgets.ALL) {
WidgetConfig c = config.widget(widget.id);
if (!c.enabled || !widget.available(state)) continue;
int w = widget.width(font, state, c), h = widget.height(font, state, c);
int[] at = place(c, sw, sh, w, h);
widget.draw(g, font, state, c, at[0], at[1], config.hud.opacity);
}
g.pose().popPose();
}
}
@@ -0,0 +1,24 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** One draggable piece of the HUD. */
public abstract class HudWidget {
public final String id;
public final String label;
protected HudWidget(String id, String label) { this.id = id; this.label = label; }
/** False when the server says this system is off, or there's nothing to show. The editor ignores this. */
public abstract boolean available(ClientState s);
public abstract int width(Font font, ClientState s, ClientConfig.WidgetConfig c);
public abstract int height(Font font, ClientState s, ClientConfig.WidgetConfig c);
/** Draw with the top-left corner at (x, y). */
public abstract void draw(GuiGraphics g, Font font, ClientState s, ClientConfig.WidgetConfig c, int x, int y, float opacity);
}
@@ -0,0 +1,109 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
import net.scopenet.client.Ui;
import java.util.List;
/** The built-in HUD widgets: level, balance, guild, claim and quests. */
public final class Widgets {
private Widgets() {}
public static final List<HudWidget> ALL = List.of(new Level(), new Balance(), new Guild(), new Claim(), new Quests());
static final class Level extends HudWidget {
Level() { super("level", "Level"); }
@Override public boolean available(ClientState s) { return s.hasState; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 64 : 118; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 22 : 34; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.ACCENT, o);
g.drawString(f, "Lv " + s.level, x + 7, y + 5, Ui.TEXT, true);
if (!c.compact) {
String title = s.title.isEmpty() ? "" : Ui.clip(f, s.title, w - 52);
g.drawString(f, title, x + w - 6 - f.width(title), y + 5, Ui.MUTED, true);
Ui.bar(g, x + 7, y + 18, w - 14, 5, s.xpPct, Ui.ACCENT, o);
String text = Math.round(s.xpPct) + "% · SMP Lv " + s.serverLevel;
g.drawString(f, text, x + 7, y + 25, Ui.MUTED, false);
} else {
Ui.bar(g, x + 7, y + 15, w - 14, 4, s.xpPct, Ui.ACCENT, o);
}
}
}
static final class Balance extends HudWidget {
Balance() { super("balance", "Balance"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featEconomy; }
private String text(ClientState s) { return Ui.money(s.currency, s.balance); }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return Math.max(c.compact ? 50 : 76, f.width(text(s)) + 18); }
@Override public int height(Font font, ClientState s, WidgetConfig c) { return c.compact ? 18 : 28; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.GOLD, o);
if (!c.compact) {
g.drawString(f, "BALANCE", x + 7, y + 4, Ui.MUTED, false);
g.drawString(f, text(s), x + 7, y + 15, Ui.GOLD, true);
} else {
g.drawString(f, text(s), x + 7, y + 5, Ui.GOLD, true);
}
}
}
static final class Guild extends HudWidget {
Guild() { super("guild", "Guild"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featGuilds && s.guild != null; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 86 : 124; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 18 : 30; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
ClientState.Guild guild = s.guild;
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.CYAN, o);
String head = "[" + guild.tag() + "] " + guild.name();
g.drawString(f, Ui.clip(f, head, w - 14), x + 7, y + 4, Ui.TEXT, true);
if (!c.compact) g.drawString(f, guild.role() + " · " + guild.claims() + " chunks", x + 7, y + 17, Ui.MUTED, false);
}
}
static final class Claim extends HudWidget {
Claim() { super("claim", "Claim"); }
@Override public boolean available(ClientState s) { return s.featClaims && !s.claimCells.isEmpty(); }
private String text(ClientState s) {
ClientState.Owner o = s.claimOwner;
if (o == null) return "Wilderness";
String name = o.tag().isEmpty() ? o.name() : "[" + o.tag() + "] " + o.name();
return (o.mine() ? "Your land: " : "Claimed: ") + name;
}
@Override public int width(Font f, ClientState s, WidgetConfig c) { return f.width(text(s)) + 18; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 18; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
ClientState.Owner owner = s.claimOwner;
int colour = owner == null ? Ui.MUTED : owner.mine() ? Ui.GREEN : Ui.RED;
Ui.card(g, x, y, w, 18, colour, o);
g.drawString(f, text(s), x + 9, y + 5, colour == Ui.MUTED ? Ui.TEXT : colour, true);
}
}
static final class Quests extends HudWidget {
Quests() { super("quests", "Quests"); }
@Override public boolean available(ClientState s) { return s.hasState && (s.daily.total() + s.weekly.total()) > 0; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return 96; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 38; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
Ui.card(g, x, y, w, 38, Ui.GREEN, o);
row(g, f, "Daily", s.daily, x + 7, y + 4, w - 14);
row(g, f, "Weekly", s.weekly, x + 7, y + 21, w - 14);
}
private void row(GuiGraphics g, Font f, String label, ClientState.Quest q, int x, int y, int w) {
g.drawString(f, label, x, y, Ui.MUTED, false);
String n = q.done() + "/" + q.total();
g.drawString(f, n, x + w - f.width(n), y, Ui.TEXT, true);
Ui.bar(g, x, y + 10, w, 3, q.total() == 0 ? 0 : 100.0 * q.done() / q.total(), Ui.GREEN, 1f);
}
}
}
@@ -0,0 +1,120 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import net.scopenet.client.hud.Hud;
import net.scopenet.client.hud.HudWidget;
import net.scopenet.client.hud.Widgets;
/**
* Drag the widgets where you want them. Right-click a widget to hide or show it, scroll to resize everything.
* Positions snap to the nearest screen anchor, so the layout holds on any window size.
*/
public final class HudEditorScreen extends Screen {
private final Screen parent;
private final ClientConfig config = ScopenetClient.config();
private final ClientState preview = ClientState.preview();
private HudWidget dragging;
private double grabDx, grabDy;
public HudEditorScreen(Screen parent) { super(Component.literal("HUD layout")); this.parent = parent; }
private float scale() { return config.hud.scale; }
private int sw() { return (int) (width / scale()); }
private int sh() { return (int) (height / scale()); }
private int[] size(HudWidget w) {
WidgetConfig c = config.widget(w.id);
return new int[]{w.width(font, preview, c), w.height(font, preview, c)};
}
private int[] origin(HudWidget w) {
int[] s = size(w);
return Hud.place(config.widget(w.id), sw(), sh(), s[0], s[1]);
}
@Override protected void init() {
addRenderableWidget(Button.builder(Component.literal("Reset"), b -> { config.resetHud(); }).bounds(width / 2 - 104, height - 28, 100, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), b -> onClose()).bounds(width / 2 + 4, height - 28, 100, 20).build());
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
g.fill(0, 0, width, height, 0x99000000);
// Faint guides at the thirds, showing where anchors switch.
for (int i = 1; i <= 2; i++) {
g.fill(width * i / 3, 0, width * i / 3 + 1, height, 0x22FFFFFF);
g.fill(0, height * i / 3, width, height * i / 3 + 1, 0x22FFFFFF);
}
g.drawCenteredString(font, "Drag to move · Right-click to show or hide · Scroll to resize", width / 2, 8, Ui.TEXT);
g.pose().pushPose();
g.pose().scale(scale(), scale(), 1f);
for (HudWidget w : Widgets.ALL) {
WidgetConfig c = config.widget(w.id);
int[] s = size(w), at = origin(w);
float opacity = c.enabled ? config.hud.opacity : 0.25f;
w.draw(g, font, preview, c, at[0], at[1], opacity);
boolean hover = Ui.inside(mouseX / scale(), mouseY / scale(), at[0], at[1], s[0], s[1]);
int outline = w == dragging ? Ui.ACCENT : hover ? Ui.TEXT : Ui.alpha(0xFFFFFF, 0.3f);
g.renderOutline(at[0] - 1, at[1] - 1, s[0] + 2, s[1] + 2, outline);
if (!c.enabled) g.drawString(font, "hidden", at[0] + 4, at[1] + s[1] + 2, Ui.RED, false);
}
g.pose().popPose();
super.render(g, mouseX, mouseY, delta);
}
private HudWidget at(double mx, double my) {
HudWidget found = null;
for (HudWidget w : Widgets.ALL) {
int[] s = size(w), o = origin(w);
if (Ui.inside(mx / scale(), my / scale(), o[0], o[1], s[0], s[1])) found = w; // later ones draw on top
}
return found;
}
@Override public boolean mouseClicked(double mx, double my, int button) {
if (super.mouseClicked(mx, my, button)) return true;
HudWidget w = at(mx, my);
if (w == null) return false;
if (button == 1) { WidgetConfig c = config.widget(w.id); c.enabled = !c.enabled; return true; }
if (button == 0) {
int[] o = origin(w);
dragging = w;
grabDx = mx / scale() - o[0];
grabDy = my / scale() - o[1];
return true;
}
return false;
}
@Override public boolean mouseDragged(double mx, double my, int button, double dx, double dy) {
if (dragging == null || button != 0) return super.mouseDragged(mx, my, button, dx, dy);
int[] s = size(dragging);
int left = (int) Math.round(mx / scale() - grabDx), top = (int) Math.round(my / scale() - grabDy);
left = Math.max(0, Math.min(sw() - s[0], left));
top = Math.max(0, Math.min(sh() - s[1], top));
Hud.anchorTo(config.widget(dragging.id), sw(), sh(), s[0], s[1], left, top);
return true;
}
@Override public boolean mouseReleased(double mx, double my, int button) {
dragging = null;
return super.mouseReleased(mx, my, button);
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
config.hud.scale = Math.max(0.5f, Math.min(2.0f, config.hud.scale + (float) delta * 0.05f));
return true;
}
@Override public void onClose() {
config.save();
minecraft.setScreen(parent);
}
}
@@ -0,0 +1,118 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.Link;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import java.util.List;
/** The player market: browse what people are selling, buy with one click, and list the item in your hand. */
public final class MarketScreen extends Screen {
private static final int W = 300, ROW = 30, LIST_TOP = 44;
private final ClientState state = ScopenetClient.state();
private int scroll;
private int age;
private int refreshIn = -1;
private EditBox price;
private Button list;
private String notice = "";
public MarketScreen() { super(Component.literal("Market")); }
private int left() { return (width - W) / 2; }
private int top() { return 12; }
private int listHeight() { return Math.max(ROW * 3, height - 24 - LIST_TOP - 64); }
@Override protected void init() {
ScopenetClient.link().send("market");
int y = top() + LIST_TOP + listHeight() + 12;
price = new EditBox(font, left() + 10, y, 70, 18, Component.literal("Price"));
price.setMaxLength(12);
price.setHint(Component.literal("Price"));
price.setFilter(s -> s.matches("[0-9]{0,9}(\\.[0-9]{0,2})?"));
addRenderableWidget(price);
list = Button.builder(Component.literal("List held item"), b -> listHeld()).bounds(left() + 86, y - 1, 100, 20).build();
addRenderableWidget(list);
addRenderableWidget(Button.builder(Component.literal("Refresh"), b -> { ScopenetClient.link().send("market"); notice = ""; }).bounds(left() + W - 70, y - 1, 60, 20).build());
addRenderableWidget(Button.builder(Component.literal("Back"), b -> minecraft.setScreen(new MenuScreen())).bounds(left() + 10, y + 26, 60, 18).build());
}
private void listHeld() {
String text = price.getValue();
if (text.isEmpty() || text.equals(".")) { notice = "Type a price first."; return; }
Link.command("market sell " + text);
price.setValue("");
notice = "Listing sent…";
refreshIn = 30;
}
@Override public void tick() {
if (++age >= 200) { age = 0; ScopenetClient.link().send("market"); }
if (refreshIn >= 0 && --refreshIn < 0) ScopenetClient.link().send("market");
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
int max = Math.max(0, state.market.size() * ROW - listHeight());
scroll = (int) Math.max(0, Math.min(max, scroll - delta * ROW / 2));
return true;
}
@Override public boolean mouseClicked(double mx, double my, int button) {
int left = left(), top = top(), lh = listHeight();
if (button == 0 && Ui.inside(mx, my, left, top + LIST_TOP, W, lh)) {
List<ClientState.Listing> items = state.market;
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (Ui.inside(mx, my, left + W - 60, y + 4, 48, ROW - 10)) {
ClientState.Listing l = items.get(i);
Link.command("market buy " + l.id());
notice = "Buying #" + l.id() + "…";
refreshIn = 30;
return true;
}
}
}
return super.mouseClicked(mx, my, button);
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = left(), top = top(), lh = listHeight();
Ui.window(g, left, top, W, LIST_TOP + lh + 76);
g.fill(left, top, left + W, top + 3, Ui.CYAN);
g.drawString(font, "Player Market", left + 10, top + 10, Ui.CYAN, true);
String bal = "Balance " + Ui.money(state.currency, state.balance);
g.drawString(font, bal, left + W - 10 - font.width(bal), top + 10, Ui.GOLD, true);
g.drawString(font, state.market.size() + " listing" + (state.market.size() == 1 ? "" : "s"), left + 10, top + 26, Ui.MUTED, false);
List<ClientState.Listing> items = state.market;
g.enableScissor(left + 1, top + LIST_TOP, left + W - 1, top + LIST_TOP + lh);
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (y + ROW < top + LIST_TOP || y > top + LIST_TOP + lh) continue;
ClientState.Listing l = items.get(i);
boolean affordable = state.balance >= l.price() || l.guildTag() != null;
g.fill(left + 6, y, left + W - 6, y + ROW - 2, Ui.alpha(Ui.PANEL_2, 0.9f));
g.renderItem(Ui.stackFor(l.itemId(), l.amount()), left + 10, y + 5);
g.renderItemDecorations(font, Ui.stackFor(l.itemId(), l.amount()), left + 10, y + 5);
g.drawString(font, Ui.clip(font, l.itemName() + " x" + l.amount(), 150), left + 32, y + 4, Ui.TEXT, true);
String seller = l.guildTag() != null ? "[" + l.guildTag() + "] guild" : l.seller();
g.drawString(font, Ui.clip(font, seller + " · #" + l.id(), 150), left + 32, y + 15, Ui.MUTED, false);
String p = Ui.money(state.currency, l.price());
g.drawString(font, p, left + W - 68 - font.width(p), y + 9, Ui.GREEN, true);
boolean hover = Ui.inside(mouseX, mouseY, left + W - 60, y + 4, 48, ROW - 10);
g.fill(left + W - 60, y + 4, left + W - 12, y + ROW - 6, affordable ? (hover ? Ui.alpha(Ui.ACCENT, 1f) : Ui.alpha(Ui.ACCENT, 0.75f)) : Ui.alpha(Ui.RED, 0.35f));
g.drawCenteredString(font, "Buy", left + W - 36, y + 10, Ui.TEXT);
}
g.disableScissor();
if (items.isEmpty()) g.drawCenteredString(font, state.marketAt == 0 ? "Loading listings…" : "No listings. Be the first to sell something!", left + W / 2, top + LIST_TOP + 24, Ui.MUTED);
if (!notice.isEmpty()) g.drawString(font, notice, left + 10, top + LIST_TOP + lh + 2, Ui.MUTED, false);
super.render(g, mouseX, mouseY, delta);
}
}
@@ -0,0 +1,55 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
/** The hub (press K): who you are on this server, and the way into the shop, market and settings. */
public final class MenuScreen extends Screen {
private static final int W = 236, H = 176;
private final ClientState state = ScopenetClient.state();
public MenuScreen() { super(Component.literal("SCOPENET")); }
@Override protected void init() {
int left = (width - W) / 2, top = (height - H) / 2;
int bw = 104, gap = 8, x1 = left + 10, x2 = left + W - 10 - bw;
boolean eco = state.connected && state.featEconomy;
Button shop = Button.builder(Component.literal("Shop"), b -> minecraft.setScreen(new ShopScreen())).bounds(x1, top + 86, bw, 20).build();
Button market = Button.builder(Component.literal("Market"), b -> minecraft.setScreen(new MarketScreen())).bounds(x2, top + 86, bw, 20).build();
shop.active = eco;
market.active = eco;
addRenderableWidget(shop);
addRenderableWidget(market);
addRenderableWidget(Button.builder(Component.literal("HUD layout"), b -> minecraft.setScreen(new HudEditorScreen(this))).bounds(x1, top + 86 + 20 + gap, bw, 20).build());
addRenderableWidget(Button.builder(Component.literal("Settings"), b -> minecraft.setScreen(new SettingsScreen(this))).bounds(x2, top + 86 + 20 + gap, bw, 20).build());
addRenderableWidget(Button.builder(Component.literal("Close"), b -> onClose()).bounds(left + (W - 80) / 2, top + H - 26, 80, 18).build());
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = (width - W) / 2, top = (height - H) / 2;
Ui.window(g, left, top, W, H);
g.fill(left, top, left + W, top + 3, Ui.ACCENT);
g.drawString(font, "SCOPENET", left + 10, top + 10, Ui.ACCENT, true);
if (!state.connected) {
g.drawCenteredString(font, "This server isn't running SCOPENET", left + W / 2, top + 50, Ui.TEXT);
g.drawCenteredString(font, "or its client link is turned off.", left + W / 2, top + 62, Ui.MUTED);
} else {
g.drawString(font, state.server.isEmpty() ? "Connected" : state.server, left + W - 10 - font.width(state.server), top + 10, Ui.MUTED, false);
g.drawString(font, state.name + (state.title.isEmpty() ? "" : " · " + state.title), left + 10, top + 28, Ui.TEXT, true);
g.drawString(font, "Level " + state.level, left + 10, top + 42, Ui.MUTED, false);
Ui.bar(g, left + 10, top + 54, W - 20, 5, state.xpPct, Ui.ACCENT, 1f);
if (state.featEconomy) g.drawString(font, Ui.money(state.currency, state.balance), left + 10, top + 66, Ui.GOLD, true);
if (state.guild != null) {
String guild = "[" + state.guild.tag() + "] " + state.guild.name();
g.drawString(font, guild, left + W - 10 - font.width(guild), top + 66, Ui.CYAN, true);
}
}
super.render(g, mouseX, mouseY, delta);
}
}
@@ -0,0 +1,110 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.AbstractSliderButton;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import net.scopenet.client.hud.Widgets;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
/** Every client option in one place. Changes apply immediately and are saved when you leave. */
public final class SettingsScreen extends Screen {
private final Screen parent;
private final ClientConfig config = ScopenetClient.config();
public SettingsScreen(Screen parent) { super(Component.literal("SCOPENET settings")); this.parent = parent; }
private static Component onOff(boolean on) { return Component.literal(on ? "§aOn" : "§cOff"); }
private Button toggle(int x, int y, int w, String label, BooleanSupplier get, Consumer<Boolean> set) {
return Button.builder(Component.literal(label + ": ").append(onOff(get.getAsBoolean())), b -> {
set.accept(!get.getAsBoolean());
b.setMessage(Component.literal(label + ": ").append(onOff(get.getAsBoolean())));
}).bounds(x, y, w, 20).build();
}
private static final class Slider extends AbstractSliderButton {
private final String label;
private final double min, max;
private final Consumer<Double> apply;
private final boolean percent;
Slider(int x, int y, int w, String label, double min, double max, double value, boolean percent, Consumer<Double> apply) {
super(x, y, w, 20, Component.empty(), (value - min) / (max - min));
this.label = label; this.min = min; this.max = max; this.apply = apply; this.percent = percent;
updateMessage();
}
private double current() { return min + (max - min) * this.value; }
@Override protected void updateMessage() {
setMessage(Component.literal(label + ": " + (percent ? Math.round(current() * 100) + "%" : String.format("%.2f", current()))));
}
@Override protected void applyValue() { apply.accept(current()); }
}
@Override protected void init() {
int colW = 150, gap = 8;
int x1 = width / 2 - colW - gap / 2, x2 = width / 2 + gap / 2;
int y = 34, step = 22;
addRenderableWidget(toggle(x1, y, colW, "HUD", () -> config.enabled, v -> config.enabled = v));
addRenderableWidget(toggle(x2, y, colW, "Hide with F3", () -> config.hud.hideInF3, v -> config.hud.hideInF3 = v));
y += step;
addRenderableWidget(new Slider(x1, y, colW, "Opacity", 0.1, 1.0, config.hud.opacity, true, v -> config.hud.opacity = v.floatValue()));
addRenderableWidget(new Slider(x2, y, colW, "Size", 0.5, 2.0, config.hud.scale, true, v -> config.hud.scale = v.floatValue()));
y += step;
addRenderableWidget(toggle(x1, y, colW, "Claim borders", () -> config.hud.claimBorders, v -> config.hud.claimBorders = v));
addRenderableWidget(toggle(x2, y, colW, "Only when sneaking", () -> config.hud.claimBordersOnlyWhenSneaking, v -> config.hud.claimBordersOnlyWhenSneaking = v));
y += step + 8;
// One button per widget: On -> Compact -> Off.
int i = 0;
for (var widget : Widgets.ALL) {
ClientConfig.WidgetConfig c = config.widget(widget.id);
int x = i % 2 == 0 ? x1 : x2;
addRenderableWidget(Button.builder(widgetLabel(widget.label, c), b -> {
if (!c.enabled) { c.enabled = true; c.compact = false; }
else if (!c.compact) c.compact = true;
else { c.enabled = false; c.compact = false; }
b.setMessage(widgetLabel(widget.label, c));
}).bounds(x, y, colW, 20).build());
if (i % 2 == 1) y += step;
i++;
}
if (i % 2 == 1) y += step;
y += 8;
addRenderableWidget(toggle(x1, y, colW, "Level-up toasts", () -> config.notifications.levelUp, v -> config.notifications.levelUp = v));
addRenderableWidget(toggle(x2, y, colW, "Achievement toasts", () -> config.notifications.achievements, v -> config.notifications.achievements = v));
y += step;
addRenderableWidget(toggle(x1, y, colW, "Guild toasts", () -> config.notifications.guild, v -> config.notifications.guild = v));
addRenderableWidget(toggle(x2, y, colW, "Error toasts", () -> config.notifications.errors, v -> config.notifications.errors = v));
y += step + 8;
addRenderableWidget(Button.builder(Component.literal("Edit HUD layout…"), b -> minecraft.setScreen(new HudEditorScreen(this))).bounds(x1, y, colW, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), b -> onClose()).bounds(x2, y, colW, 20).build());
}
private static Component widgetLabel(String label, ClientConfig.WidgetConfig c) {
return Component.literal(label + ": ").append(Component.literal(!c.enabled ? "§cOff" : c.compact ? "§eCompact" : "§aOn"));
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
g.drawCenteredString(font, "SCOPENET settings", width / 2, 14, Ui.ACCENT);
super.render(g, mouseX, mouseY, delta);
}
@Override public void onClose() {
config.save();
minecraft.setScreen(parent);
}
}
@@ -0,0 +1,92 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.Link;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import java.util.List;
/** The server shop: what each item sells for, and a button to sell what's in your hand. */
public final class ShopScreen extends Screen {
private static final int W = 280, ROW = 24, LIST_TOP = 44;
private final ClientState state = ScopenetClient.state();
private int scroll;
private int age;
private Button sell;
public ShopScreen() { super(Component.literal("Shop")); }
private int left() { return (width - W) / 2; }
private int top() { return 12; }
private int listHeight() { return Math.max(ROW * 3, height - 24 - LIST_TOP - 54); }
@Override protected void init() {
ScopenetClient.link().send("shop");
sell = Button.builder(Component.literal("Sell held item"), b -> { Link.command("sell hand"); age = -30; })
.bounds(left() + W - 130, top() + LIST_TOP + listHeight() + 10, 120, 20).build();
addRenderableWidget(sell);
addRenderableWidget(Button.builder(Component.literal("Back"), b -> minecraft.setScreen(new MenuScreen())).bounds(left() + 10, top() + LIST_TOP + listHeight() + 34, 60, 18).build());
}
@Override public void tick() {
if (++age >= 40) { age = 0; ScopenetClient.link().send("shop"); }
ClientState.Held held = state.held;
sell.active = held != null && held.unit() > 0;
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
int max = Math.max(0, state.shop.size() * ROW - listHeight());
scroll = (int) Math.max(0, Math.min(max, scroll - delta * ROW));
return true;
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = left(), top = top(), lh = listHeight();
Ui.window(g, left, top, W, LIST_TOP + lh + 62);
g.fill(left, top, left + W, top + 3, Ui.GOLD);
g.drawString(font, "Server Shop", left + 10, top + 10, Ui.GOLD, true);
String bal = Ui.money(state.currency, state.balance);
g.drawString(font, bal, left + W - 10 - font.width(bal), top + 10, Ui.TEXT, true);
g.drawString(font, "Hold items and press Sell. Prices are per item.", left + 10, top + 26, Ui.MUTED, false);
List<ClientState.ShopItem> items = state.shop;
ClientState.Held held = state.held;
g.enableScissor(left + 1, top + LIST_TOP, left + W - 1, top + LIST_TOP + lh);
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (y + ROW < top + LIST_TOP || y > top + LIST_TOP + lh) continue;
ClientState.ShopItem item = items.get(i);
boolean hover = Ui.inside(mouseX, mouseY, left + 6, y, W - 12, ROW - 2);
boolean match = held != null && held.id().equalsIgnoreCase(item.id());
g.fill(left + 6, y, left + W - 6, y + ROW - 2, match ? Ui.alpha(Ui.GOLD, 0.18f) : hover ? Ui.alpha(0xFFFFFF, 0.06f) : Ui.alpha(Ui.PANEL_2, 0.9f));
g.renderItem(Ui.stackFor(item.id(), 1), left + 10, y + 3);
g.drawString(font, Ui.clip(font, title(item.name()), 130), left + 32, y + 8, Ui.TEXT, true);
String price = Ui.money(state.currency, item.price());
g.drawString(font, price, left + W - 12 - font.width(price), y + 8, Ui.GREEN, true);
}
g.disableScissor();
if (items.isEmpty()) g.drawCenteredString(font, "Loading prices…", left + W / 2, top + LIST_TOP + 20, Ui.MUTED);
int infoY = top + LIST_TOP + lh + 14;
if (held == null) g.drawString(font, "Your hand is empty", left + 10, infoY, Ui.MUTED, false);
else {
g.renderItem(Ui.stackFor(held.id(), held.count()), left + 10, infoY - 4);
g.drawString(font, Ui.clip(font, held.name() + " x" + held.count(), 100), left + 30, infoY - 2, Ui.TEXT, true);
g.drawString(font, held.unit() > 0 ? "worth " + Ui.money(state.currency, held.unit() * held.count()) : "can't be sold here", left + 30, infoY + 9, held.unit() > 0 ? Ui.GREEN : Ui.RED, false);
}
super.render(g, mouseX, mouseY, delta);
}
private static String title(String name) {
if (name.isEmpty()) return name;
StringBuilder out = new StringBuilder();
for (String part : name.split(" ")) out.append(part.isEmpty() ? "" : Character.toUpperCase(part.charAt(0)) + part.substring(1)).append(' ');
return out.toString().trim();
}
}
@@ -0,0 +1,5 @@
{
"key.categories.scopenet": "SCOPENET",
"key.scopenet.menu": "Open SCOPENET menu",
"key.scopenet.toggle_hud": "Show or hide SCOPENET HUD"
}
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"id": "scopenet_client",
"version": "${version}",
"name": "SCOPENET Client",
"description": "HUD widgets, claim borders, and shop and market windows for SCOPENET servers. Display only: the server stays in charge of money, claims and permissions.",
"environment": "client",
"entrypoints": { "client": ["net.scopenet.client.ScopenetClient"] },
"depends": { "fabricloader": ">=0.16.10", "minecraft": "1.20.1", "java": ">=17", "fabric-api": "*" }
}
+7
View File
@@ -8,12 +8,19 @@ if (providers.gradleProperty('verifyMixins').isPresent()) {
} }
java.sourceCompatibility = JavaVersion.toVersion(targetJava) java.sourceCompatibility = JavaVersion.toVersion(targetJava)
java.targetCompatibility = JavaVersion.toVersion(targetJava) java.targetCompatibility = JavaVersion.toVersion(targetJava)
def features = mc == '1.20.1' // commands, permissions, economy, client link: Fabric API based, 1.20.1 only for now
dependencies { dependencies {
minecraft "com.mojang:minecraft:${mc}" minecraft "com.mojang:minecraft:${mc}"
mappings loom.officialMojangMappings() mappings loom.officialMojangMappings()
modImplementation 'net.fabricmc:fabric-loader:0.16.10' modImplementation 'net.fabricmc:fabric-loader:0.16.10'
implementation project(':common') implementation project(':common')
if (features) {
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
// Optional at runtime: present only when the LuckPerms mod is installed.
compileOnly 'net.luckperms:api:5.4'
} }
}
if (features) sourceSets.main.java.srcDirs += ['src/mc1.20.1/java']
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"] sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources'] sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
apply from: '../minecraft/build-info.gradle' apply from: '../minecraft/build-info.gradle'
@@ -1,11 +1,22 @@
package net.scopenet.fabric; package net.scopenet.fabric;
import net.fabricmc.api.DedicatedServerModInitializer; import net.fabricmc.api.DedicatedServerModInitializer;
import net.fabricmc.loader.api.FabricLoader;
/** Server-only; lifecycle and admission hooks are shared mixins. */ /** Server-only; lifecycle and admission hooks are shared mixins. Commands and extras need Fabric API (1.20.1 build). */
public final class ScopenetFabric implements DedicatedServerModInitializer { public final class ScopenetFabric implements DedicatedServerModInitializer {
@Override public void onInitializeServer() { @Override public void onInitializeServer() {
if (Boolean.getBoolean("scopenet.verifyMixins")) if (Boolean.getBoolean("scopenet.verifyMixins"))
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit(); org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
if (!FabricLoader.getInstance().isModLoaded("fabric-api")) return;
try {
Class<?> features = Class.forName("net.scopenet.fabric.features.FabricFeatures");
((Runnable) features.getDeclaredConstructor().newInstance()).run();
} catch (ClassNotFoundException e) {
// This build (another Minecraft version) only has the core integration.
} catch (Throwable t) {
System.err.println("[SCOPENET] Commands and extras could not start: " + t);
t.printStackTrace();
}
} }
} }
@@ -6,5 +6,6 @@
"environment": "server", "environment": "server",
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] }, "entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"], "mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" } "depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
} }
@@ -0,0 +1,60 @@
package net.scopenet.fabric.api;
import com.google.gson.JsonObject;
import net.scopenet.core.Panel;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
/**
* SCOPENET's economy for other mods (the Fabric stand-in for Vault). Balances live in the panel, so every call is
* asynchronous. Every change carries an operation id, so a retried call never pays twice.
*
* <pre>{@code
* ScopenetEconomy.get().ifPresent(eco -> eco.balance(uuid).thenAccept(b -> ...));
* }</pre>
*/
public final class ScopenetEconomy {
private static final AtomicReference<ScopenetEconomy> INSTANCE = new AtomicReference<>();
private final Panel panel;
private final java.util.concurrent.Executor io;
public ScopenetEconomy(Panel panel, java.util.concurrent.Executor io) { this.panel = panel; this.io = io; }
/** Empty until the server has started and SCOPENET economy is enabled. */
public static java.util.Optional<ScopenetEconomy> get() { return java.util.Optional.ofNullable(INSTANCE.get()); }
public static void install(ScopenetEconomy economy) { INSTANCE.set(economy); }
public static void uninstall() { INSTANCE.set(null); }
public CompletableFuture<Double> balance(UUID player, String name) {
JsonObject body = new JsonObject();
body.addProperty("uuid", player.toString());
body.addProperty("username", name);
return CompletableFuture.supplyAsync(() -> {
try { return panel.call("economy/balance", body).getAsJsonObject().get("balance").getAsDouble(); }
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
}, io);
}
/** Add (positive) or remove (negative) money. Fails, without changing anything, if the player can't afford it. Returns the new balance. */
public CompletableFuture<Double> change(UUID player, String name, double delta, String reason) {
JsonObject body = new JsonObject();
body.addProperty("operation_id", UUID.randomUUID().toString());
body.addProperty("uuid", player.toString());
body.addProperty("username", name);
body.addProperty("delta", delta);
body.addProperty("description", reason == null || reason.isBlank() ? "Other mod" : reason);
return CompletableFuture.supplyAsync(() -> {
try { return panel.call("economy/adjust", body).getAsJsonObject().get("balance").getAsDouble(); }
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
}, io);
}
public CompletableFuture<Double> deposit(UUID player, String name, double amount, String reason) { return change(player, name, Math.abs(amount), reason); }
public CompletableFuture<Double> withdraw(UUID player, String name, double amount, String reason) { return change(player, name, -Math.abs(amount), reason); }
}
@@ -0,0 +1,47 @@
package net.scopenet.fabric.features;
import net.scopenet.core.EssentialsConfig;
import net.scopenet.core.Features;
import net.scopenet.integration.Settings;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/** The Fabric-only keys in config/scopenet.properties (the shared keys are read by {@link Settings}). */
final class FabricConfig {
static final Path FILE = Path.of("config", "scopenet.properties");
private final Properties p = new Properties();
static FabricConfig load() {
FabricConfig c = new FabricConfig();
if (Files.exists(FILE)) {
try (Reader in = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { c.p.load(in); }
catch (IOException e) { System.err.println("[SCOPENET] Could not read " + FILE + ": " + e.getMessage()); }
}
return c;
}
private int integer(String key, int fallback) {
try { return Integer.parseInt(p.getProperty(key, String.valueOf(fallback)).trim()); } catch (NumberFormatException e) { return fallback; }
}
boolean bool(String key, boolean fallback) { return Boolean.parseBoolean(p.getProperty(key, String.valueOf(fallback)).trim()); }
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
EssentialsConfig essentials() {
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);
}
Features features(Settings s) {
return new Features(s.essentialsEnabled(), s.economyEnabled(), s.guildsEnabled(), s.socialEnabled(), s.landClaimingEnabled(),
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
}
/** {@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"); }
}
@@ -0,0 +1,214 @@
package net.scopenet.fabric.features;
import com.google.gson.JsonArray;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.*;
import net.scopenet.fabric.api.ScopenetEconomy;
import net.scopenet.integration.Integration;
import net.scopenet.integration.Settings;
import net.scopenet.minecraft.Bridge;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
/**
* Everything beyond sign-in on Fabric 1.20.1: the shared commands (homes, warps, TPA, RTP, economy, guilds…), LuckPerms
* permissions, Placeholder API, the economy API for other mods, and the link to the optional client mod.
* Runs when Fabric API is installed; all behaviour comes from {@code net.scopenet.core}.
*/
public final class FabricFeatures implements Runnable {
private static final Logger LOG = Logger.getLogger("SCOPENET");
private static final ResourceLocation TO_CLIENT = new ResourceLocation(Wire.S2C);
private static final ResourceLocation FROM_CLIENT = new ResourceLocation(Wire.C2S);
private MinecraftServer server;
private FabricPlatform platform;
private Env env;
private CommandSet commands;
private PlayerCache cache;
private ClientLink link;
private Perms perms;
private FabricConfig config;
private int ticks;
private String placeholderStatus = "off";
CommandSet commandSet() { return commands; }
@Override public void run() {
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
ServerPlayConnectionEvents.DISCONNECT.register((handler, s) -> { if (link != null) link.disconnected(handler.getPlayer().getUUID()); });
// /back also returns you to where you died.
ServerLivingEntityEvents.ALLOW_DEATH.register((entity, source, amount) -> {
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
return true;
});
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data);
s.execute(() -> { if (link != null) link.receive(platform.wrap(player), data); });
});
}
// ---- lifecycle -------------------------------------------------------------------------
private void started(MinecraftServer s) {
Integration integration = Bridge.integration();
if (integration == null) { LOG.warning("SCOPENET is not connected to a panel; commands are off."); return; }
server = s;
config = FabricConfig.load();
perms = new Perms(config.everyoneByDefault());
platform = new FabricPlatform(s, perms);
Path dir = Path.of("config", "scopenet");
env = new Env(platform, new PanelAdapter(integration), config.features(integration.settings()), dir, System::currentTimeMillis, new Random(), LOG);
commands = new CommandSet(env, config.essentials());
cache = new PlayerCache(env);
link = new ClientLink(env, cache, s.getMotd(), (player, bytes) -> {
FriendlyByteBuf buf = PacketByteBufs.create();
buf.writeBytes(bytes);
ServerPlayer target = s.getPlayerList().getPlayer(player.uuid());
if (target != null && ServerPlayNetworking.canSend(target, TO_CLIENT)) ServerPlayNetworking.send(target, TO_CLIENT, buf);
});
integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
registerCommands(s.getCommands().getDispatcher());
registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
LOG.info("SCOPENET commands ready (" + commands.all().size() + " commands, "
+ (Perms.luckPermsPresent() ? "LuckPerms" : "default permissions") + ").");
}
private void stopping() {
ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false;
if (platform != null) platform.shutdown();
commands = null;
}
private void registerPlaceholders() {
if (!config.bool("placeholders.enabled", true)) { placeholderStatus = "off (config)"; return; }
if (!net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("placeholder-api")) { placeholderStatus = "Placeholder API not installed"; return; }
try {
int n = PlaceholderApiModule.register(new Placeholders(env, cache));
placeholderStatus = "active (" + n + ")";
} catch (Throwable t) {
placeholderStatus = "failed: " + t;
LOG.warning("Placeholder API could not be used: " + t);
}
}
private void tick() {
if (commands == null) return;
ticks++;
if (ticks % 20 == 0) {
cache.tick(p -> link.pushState(p));
link.tick();
}
if (ticks % 40 == 0) commands.tick();
}
// ---- commands --------------------------------------------------------------------------
private void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
List<CoreCommand> all = new ArrayList<>(commands.all());
all.add(new ScopenetCommand(this));
for (CoreCommand command : all) {
List<String> names = new ArrayList<>();
names.add(command.name());
names.addAll(command.aliases());
for (String name : names) {
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal(name)
.executes(ctx -> execute(ctx, command, ""))
.then(Commands.argument("args", StringArgumentType.greedyString())
.suggests((ctx, builder) -> suggest(ctx, command, builder))
.executes(ctx -> execute(ctx, command, StringArgumentType.getString(ctx, "args"))));
dispatcher.register(root);
}
}
}
private int execute(CommandContext<CommandSourceStack> ctx, CoreCommand command, String argText) {
ServerPlayer sp = ctx.getSource().getPlayer();
if (sp == null) { ctx.getSource().sendFailure(Component.literal("Only players can use this command.")); return 0; }
String[] args = argText.isBlank() ? new String[0] : argText.trim().split("\\s+");
CorePlayer player = platform.wrap(sp);
try {
command.run(player, args);
} catch (RuntimeException e) {
LOG.warning("/" + command.name() + " failed: " + e);
player.send(Format.RED + "Something went wrong running that command.");
return 0;
}
return 1;
}
private CompletableFuture<Suggestions> suggest(CommandContext<CommandSourceStack> ctx, CoreCommand command, SuggestionsBuilder builder) {
ServerPlayer sp = ctx.getSource().getPlayer();
if (sp != null) {
String before = builder.getInput().substring(0, builder.getStart()).stripLeading();
if (before.startsWith("/")) before = before.substring(1);
String[] tokens = before.isBlank() ? new String[0] : before.split(" ");
List<String> typed = new ArrayList<>(Arrays.asList(tokens).subList(Math.min(1, tokens.length), tokens.length));
typed.add(builder.getRemaining());
String partial = builder.getRemaining().toLowerCase(Locale.ROOT);
for (String candidate : command.complete(platform.wrap(sp), typed.toArray(new String[0]))) {
if (candidate.toLowerCase(Locale.ROOT).startsWith(partial)) builder.suggest(candidate);
}
}
return builder.buildFuture();
}
// ---- /scopenet -------------------------------------------------------------------------
void sendStatus(CorePlayer p) {
Integration integration = Bridge.integration();
Settings s = integration.settings();
p.send(Format.GOLD + "=== SCOPENET Status ===");
p.send(Format.GRAY + "Panel: " + Format.WHITE + s.panel() + Format.GRAY + " Token: " + (s.token().isEmpty() ? Format.RED + "missing" : Format.GREEN + "set"));
Features f = env.features;
p.send(Format.GRAY + "Essentials: " + flag(f.essentials()) + Format.GRAY + " Economy: " + flag(f.economy()) + Format.GRAY + " Guilds: " + flag(f.guilds())
+ Format.GRAY + " Claims: " + flag(f.landClaiming()) + Format.GRAY + " Client link: " + flag(f.clientLink()));
p.send(Format.GRAY + "Claim index: " + (integration.client().claims().loaded() ? Format.GREEN + integration.client().claims().claimCount() + " claims" : Format.YELLOW + "loading"));
p.send(Format.GRAY + "Permissions: " + Format.WHITE + (Perms.luckPermsPresent() ? "LuckPerms" : "defaults (" + (config.everyoneByDefault() ? "everyone" : "operators") + ")"));
p.send(Format.GRAY + "Placeholder API: " + Format.WHITE + placeholderStatus);
p.send(Format.GRAY + "Pending transactions: " + Format.WHITE + commands.jobs.pending());
}
private static String flag(boolean on) { return on ? Format.GREEN + "on" : Format.RED + "off"; }
void reload(CorePlayer p) {
try {
Settings next = Settings.load(FabricConfig.FILE);
Bridge.integration().reload(next);
config = FabricConfig.load();
perms = new Perms(config.everyoneByDefault());
env.features = config.features(next);
commands.essentials.configure(config.essentials());
p.send(Format.GREEN + "SCOPENET config reloaded.");
} catch (Exception e) {
p.send(Format.RED + "Reload failed: " + e.getMessage());
}
}
}
@@ -0,0 +1,34 @@
package net.scopenet.fabric.features;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.TagParser;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.scopenet.core.Item;
/** ItemStack <-> the shared {@link Item}. The exact stack (enchantments, names, NBT) travels as SNBT in {@code data}. */
final class FabricItems {
private FabricItems() {}
static Item toCore(ItemStack stack) {
String registryId = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
return new Item(Item.panelId(registryId), stack.getHoverName().getString(), stack.getCount(), stack.save(new CompoundTag()).toString());
}
/** The stack for an item, restoring the exact NBT when it came from this platform; otherwise rebuilt from id and count. */
static ItemStack fromCore(Item item) {
if (!item.data().isEmpty()) {
try {
ItemStack stack = ItemStack.of(TagParser.parseTag(item.data()));
if (!stack.isEmpty()) return stack;
} catch (Exception ignored) {
// Another platform's serialisation: fall through to id and count.
}
}
ResourceLocation id = ResourceLocation.tryParse(Item.registryId(item.id()));
net.minecraft.world.item.Item type = id == null ? null : BuiltInRegistries.ITEM.get(id);
if (type == null || type == net.minecraft.world.item.Items.AIR) return ItemStack.EMPTY;
return new ItemStack(type, Math.max(1, item.count()));
}
}
@@ -0,0 +1,75 @@
package net.scopenet.fabric.features;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Platform;
import net.scopenet.core.Pos;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** The shared logic's view of a running Fabric server. */
final class FabricPlatform implements Platform {
private final MinecraftServer server;
private final Perms perms;
private final ExecutorService io = Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r, "scopenet-io");
t.setDaemon(true);
return t;
});
FabricPlatform(MinecraftServer server, Perms perms) { this.server = server; this.perms = perms; }
void shutdown() { io.shutdownNow(); }
CorePlayer wrap(ServerPlayer p) { return new FabricPlayer(p, this, perms); }
ServerLevel level(String id) {
ResourceLocation location = ResourceLocation.tryParse(id);
return location == null ? null : server.getLevel(ResourceKey.create(Registries.DIMENSION, location));
}
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(server.getPlayerList().getPlayer(uuid)).map(this::wrap); }
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(server.getPlayerList().getPlayerByName(name)).map(this::wrap); }
@Override public Collection<? extends CorePlayer> online() {
List<CorePlayer> out = new ArrayList<>();
for (ServerPlayer p : server.getPlayerList().getPlayers()) out.add(wrap(p));
return out;
}
@Override public Optional<Pos> safeSurface(String world, int x, int z) {
ServerLevel level = level(world);
if (level == null || level.dimensionType().hasCeiling()) return Optional.empty(); // the Nether roof is bedrock
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
if (y <= level.getMinBuildHeight() + 1) return Optional.empty();
BlockState ground = level.getBlockState(new BlockPos(x, y - 1, z));
if (!ground.blocksMotion() || !ground.getFluidState().isEmpty()) return Optional.empty();
return Optional.of(new Pos(world, x + 0.5, y, z + 0.5, 0, 0));
}
@Override public Pos worldSpawn(String world) {
ServerLevel level = level(world);
if (level == null) level = server.overworld();
BlockPos spawn = level.getSharedSpawnPos();
return new Pos(level.dimension().location().toString(), spawn.getX(), spawn.getY(), spawn.getZ(), 0, 0);
}
@Override public void runMain(Runnable task) { server.execute(task); }
@Override public void runAsync(Runnable task) { io.execute(task); }
}
@@ -0,0 +1,63 @@
package net.scopenet.fabric.features;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Item;
import net.scopenet.core.Pos;
import java.util.Optional;
import java.util.UUID;
/** A {@link CorePlayer} over a Fabric ServerPlayer. */
final class FabricPlayer implements CorePlayer {
private final ServerPlayer player;
private final FabricPlatform platform;
private final Perms perms;
FabricPlayer(ServerPlayer player, FabricPlatform platform, Perms perms) {
this.player = player; this.platform = platform; this.perms = perms;
}
ServerPlayer handle() { return player; }
@Override public UUID uuid() { return player.getUUID(); }
@Override public String name() { return player.getGameProfile().getName(); }
@Override public Pos pos() {
ServerLevel level = player.serverLevel();
return new Pos(level.dimension().location().toString(), player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
}
@Override public void send(String message) { player.sendSystemMessage(Component.literal(message)); }
@Override public void teleport(Pos destination) {
ServerLevel level = platform.level(destination.world());
if (level == null) { send("§cThat dimension is not loaded."); return; }
player.teleportTo(level, destination.x(), destination.y(), destination.z(), destination.yaw(), destination.pitch());
}
@Override public boolean hasPermission(String node) { return perms.has(player, node); }
@Override public long playtimeSeconds() {
return player.getStats().getValue(Stats.CUSTOM.get(Stats.PLAY_TIME)) / 20L;
}
@Override public Optional<Item> heldItem() {
ItemStack stack = player.getMainHandItem();
return stack.isEmpty() ? Optional.empty() : Optional.of(FabricItems.toCore(stack));
}
@Override public void clearHeld() { player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); }
@Override public void give(Item item) {
ItemStack stack = FabricItems.fromCore(item);
if (stack.isEmpty()) { send("§cCould not deliver " + item.id() + " x" + item.count() + ". Ask an admin."); return; }
player.getInventory().add(stack);
if (!stack.isEmpty()) player.drop(stack, false);
}
}
@@ -0,0 +1,21 @@
package net.scopenet.fabric.features;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User;
import net.luckperms.api.util.Tristate;
import net.minecraft.server.level.ServerPlayer;
/** Only loaded when the LuckPerms mod is present (see {@link Perms}). */
final class LuckPermsLookup {
private LuckPermsLookup() {}
/** True or false when LuckPerms has the node set for this player, null when it has no opinion. */
static Boolean check(ServerPlayer player, String node) {
LuckPerms api = LuckPermsProvider.get();
User user = api.getUserManager().getUser(player.getUUID());
if (user == null) return null;
Tristate result = user.getCachedData().getPermissionData().checkPermission(node);
return result == Tristate.UNDEFINED ? null : result.asBoolean();
}
}
@@ -0,0 +1,36 @@
package net.scopenet.fabric.features;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.server.level.ServerPlayer;
/**
* Permission checks. With the LuckPerms mod installed its answer wins whenever the node is set; otherwise (or when
* LuckPerms has no opinion) the defaults apply: ordinary commands follow {@code permissions.default_level}, and
* admin nodes need operator level 2. Same node names as the Paper plugin, so one LuckPerms setup serves both.
*/
final class Perms {
private static final boolean LUCKPERMS = FabricLoader.getInstance().isModLoaded("luckperms");
private final boolean everyoneByDefault;
Perms(boolean everyoneByDefault) { this.everyoneByDefault = everyoneByDefault; }
static boolean luckPermsPresent() { return LUCKPERMS; }
static boolean isAdminNode(String node) {
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
}
boolean has(ServerPlayer player, String node) {
if (LUCKPERMS) {
try {
Boolean answer = LuckPermsLookup.check(player, node);
if (answer != null) return answer;
} catch (Throwable t) {
// LuckPerms not ready yet or an API change: fall back to the defaults below.
}
}
if (isAdminNode(node)) return player.hasPermissions(2);
return everyoneByDefault || player.hasPermissions(2);
}
}
@@ -0,0 +1,43 @@
package net.scopenet.fabric.features;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.Placeholders;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Optional;
/**
* Registers %scopenet:level%, %scopenet:balance%… with Placeholder API (Fabric) when it is installed. Done through
* reflection so SCOPENET builds and runs without it and tolerates small API differences; any problem just leaves
* the placeholders off and is reported in /scopenet status.
*/
final class PlaceholderApiModule {
private PlaceholderApiModule() {}
/** Returns how many placeholders were registered; throws if the API isn't usable. */
static int register(Placeholders values) throws ReflectiveOperationException {
Class<?> placeholders = Class.forName("eu.pb4.placeholders.api.Placeholders");
Class<?> handlerType = Class.forName("eu.pb4.placeholders.api.PlaceholderHandler");
Class<?> resultType = Class.forName("eu.pb4.placeholders.api.PlaceholderResult");
Method register = placeholders.getMethod("register", ResourceLocation.class, handlerType);
Method value = resultType.getMethod("value", String.class);
Method invalid = resultType.getMethod("invalid", String.class);
int count = 0;
for (String key : Placeholders.KEYS) {
Object handler = Proxy.newProxyInstance(handlerType.getClassLoader(), new Class<?>[]{handlerType}, (proxy, method, args) -> {
if (method.getDeclaringClass() == Object.class) return method.invoke(new Object(), args);
Object context = args[0];
ServerPlayer player = (ServerPlayer) context.getClass().getMethod("player").invoke(context);
if (player == null) return invalid.invoke(null, "No player");
Optional<String> text = values.resolve(player.getUUID(), key);
return text.isPresent() ? value.invoke(null, text.get()) : invalid.invoke(null, "Unknown placeholder");
});
register.invoke(null, new ResourceLocation("scopenet", key), handler);
count++;
}
return count;
}
}
@@ -0,0 +1,55 @@
package net.scopenet.fabric.features;
import net.scopenet.core.CommandSet;
import net.scopenet.core.CoreCommand;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Format;
import java.util.List;
import java.util.Locale;
/** /scopenet help | status | reload (alias /sn), with the same nodes as the Paper plugin. */
final class ScopenetCommand implements CoreCommand {
private final FabricFeatures features;
ScopenetCommand(FabricFeatures features) { this.features = features; }
@Override public String name() { return "scopenet"; }
@Override public List<String> aliases() { return List.of("sn"); }
@Override public String permission() { return "scopenet.command.scopenet"; }
@Override public List<String> complete(CorePlayer player, String[] args) {
return args.length <= 1 ? List.of("help", "status", "reload") : List.of();
}
@Override public void run(CorePlayer p, String[] args) {
String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT);
switch (sub) {
case "help" -> {
if (!p.hasPermission("scopenet.command.scopenet.help")) { p.send(Format.RED + "You do not have permission to view SCOPENET help."); return; }
help(p);
}
case "status" -> {
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
features.sendStatus(p);
}
case "reload" -> {
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
features.reload(p);
}
default -> p.send(Format.RED + "Unknown command. Use " + Format.YELLOW + "/scopenet help");
}
}
private void help(CorePlayer p) {
p.send(Format.GOLD + "=================== " + Format.YELLOW + "SCOPENET Commands" + Format.GOLD + " ===================");
CommandSet set = features.commandSet();
if (set == null) return;
for (CoreCommand c : set.all()) {
if (!p.hasPermission(c.permission())) continue;
String aliases = c.aliases().isEmpty() ? "" : Format.DARK_GRAY + " (" + String.join(", ", c.aliases().stream().map(a -> "/" + a).toList()) + ")";
p.send(Format.YELLOW + " /" + c.name() + aliases);
}
p.send(Format.GRAY + "Open the launcher's Command guide for what each one does.");
}
}
@@ -16,6 +16,12 @@ public final class Bridge {
private static int mapTicks; private static int mapTicks;
private Bridge() {} private Bridge() {}
/** The running integration, or null before the server has started. */
public static Integration integration() { return integration; }
/** Lets a permissions system (LuckPerms) say who may build in other guilds' claims. Default: nobody. */
public static volatile java.util.function.Predicate<ServerPlayer> claimBypass = player -> false;
public static void start(MinecraftServer server) { public static void start(MinecraftServer server) {
try { try {
integration = new Integration(Settings.load(Path.of("config/scopenet.properties")), integration = new Integration(Settings.load(Path.of("config/scopenet.properties")),
@@ -103,6 +109,7 @@ public final class Bridge {
Integration current = integration; Integration current = integration;
if (current == null) return false; if (current == null) return false;
if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true; if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true;
if (claimBypass.test(player)) return true;
String dimension = Compat.dimension(player); String dimension = Compat.dimension(player);
ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID()); ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID());
if (check.allowed()) return true; if (check.allowed()) return true;
@@ -0,0 +1,108 @@
package net.scopenet.paper;
import net.scopenet.core.*;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.nio.file.Path;
import java.util.*;
import java.util.logging.Logger;
/**
* Talks to the optional SCOPENET client mod (HUD, claim overlay, shop and market windows) through plugin messaging,
* using the same shared logic the Fabric server mod uses. Display only: the client's actions are ordinary commands.
*/
final class ClientLinkBridge implements PluginMessageListener {
private final ScopenetPlugin plugin;
private final Env env;
private final PlayerCache cache;
private final ClientLink link;
private final Placeholders placeholders;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
PaperPlatform platform = new PaperPlatform(plugin);
Features features = new Features(true, plugin.getConfig().getBoolean("economy.enabled", true), integration.settings().guildsEnabled(),
integration.settings().socialEnabled(), integration.settings().landClaimingEnabled(),
plugin.getConfig().getBoolean("clientlink.enabled", true), plugin.getConfig().getString("economy.currency-symbol", "$"));
Logger log = plugin.getLogger();
env = new Env(platform, new PanelAdapter(integration), features, Path.of(plugin.getDataFolder().getPath()), System::currentTimeMillis, new Random(), log);
cache = new PlayerCache(env);
placeholders = new Placeholders(env, cache);
link = new ClientLink(env, cache, Bukkit.getServer().getName(), (p, bytes) -> {
Player target = Bukkit.getPlayer(p.uuid());
if (target != null) target.sendPluginMessage(plugin, Wire.S2C, bytes);
});
}
ClientLink link() { return link; }
void start() {
Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().registerIncomingPluginChannel(plugin, Wire.C2S, this);
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
cache.tick(p -> link.pushState(p));
link.tick();
}, 40, 20);
}
void stop() {
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
}
void quit(Player player) { link.disconnected(player.getUniqueId()); }
@Override public void onPluginMessageReceived(String channel, Player player, byte[] message) {
if (!Wire.C2S.equals(channel)) return;
link.receive(new PaperPlatform.PaperPlayer(player), message);
}
/** Panel events (level-ups, achievements, guild changes) become toasts on clients. Any thread. */
void panelEvents(com.google.gson.JsonArray events) { link.onPanelEvents(events); }
/** Bukkit adapters for the shared logic. Only what the client link needs is real. */
static final class PaperPlatform implements Platform {
private final ScopenetPlugin plugin;
PaperPlatform(ScopenetPlugin plugin) { this.plugin = plugin; }
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(Bukkit.getPlayer(uuid)).map(PaperPlayer::new); }
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(Bukkit.getPlayerExact(name)).map(PaperPlayer::new); }
@Override public Collection<? extends CorePlayer> online() {
List<CorePlayer> out = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) out.add(new PaperPlayer(p));
return out;
}
@Override public Optional<Pos> safeSurface(String world, int x, int z) { return Optional.empty(); }
@Override public Pos worldSpawn(String world) { return new Pos(world, 0, 64, 0, 0, 0); }
@Override public void runMain(Runnable task) {
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
}
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
record PaperPlayer(Player p) implements CorePlayer {
@Override public UUID uuid() { return p.getUniqueId(); }
@Override public String name() { return p.getName(); }
@Override public Pos pos() {
org.bukkit.Location l = p.getLocation();
return new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
}
@Override public void send(String message) { p.sendMessage(message); }
@Override public void teleport(Pos destination) { }
@Override public boolean hasPermission(String node) { return p.hasPermission(node); }
@Override public long playtimeSeconds() { return p.getStatistic(org.bukkit.Statistic.PLAY_ONE_MINUTE) / 20L; }
@Override public Optional<Item> heldItem() {
ItemStack hand = p.getInventory().getItemInMainHand();
if (hand.getType() == Material.AIR) return Optional.empty();
String name = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName() ? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
return Optional.of(new Item(hand.getType().name(), name, hand.getAmount(), ""));
}
@Override public void clearHeld() { }
@Override public void give(Item item) { }
}
}
}
@@ -24,6 +24,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private EconomyHandler economy; private EconomyHandler economy;
private ScopenetApiImpl api; private ScopenetApiImpl api;
private CompatManager compat; private CompatManager compat;
private ClientLinkBridge clientLink;
@Override public void onEnable() { @Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid. // Install the deny-by-default listener even if configuration is invalid.
@@ -58,7 +59,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
api = new ScopenetApiImpl(integration.client()); api = new ScopenetApiImpl(integration.client());
ScopenetApiProvider.register(api); ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal); getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
integration.onNotifications(new EventBridge(this, api, getLogger())::accept); // Optional client mod (HUD, claim overlay, shop and market windows).
clientLink = new ClientLinkBridge(this, integration);
clientLink.start();
EventBridge events = new EventBridge(this, api, getLogger());
integration.onNotifications(list -> { events.accept(list); clientLink.panelEvents(list); });
Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20); Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
// Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and // Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
@@ -180,6 +185,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@Override public void onDisable() { @Override public void onDisable() {
if (compat != null) compat.stop(); if (compat != null) compat.stop();
if (clientLink != null) clientLink.stop();
if (api != null) { if (api != null) {
getServer().getServicesManager().unregister(ScopenetApi.class, api); getServer().getServicesManager().unregister(ScopenetApi.class, api);
ScopenetApiProvider.unregister(api); ScopenetApiProvider.unregister(api);
@@ -213,6 +219,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
public void leave(PlayerQuitEvent event) { public void leave(PlayerQuitEvent event) {
deniedAt.remove(event.getPlayer().getUniqueId()); deniedAt.remove(event.getPlayer().getUniqueId());
if (api != null) api.forget(event.getPlayer().getUniqueId()); if (api != null) api.forget(event.getPlayer().getUniqueId());
if (clientLink != null) clientLink.quit(event.getPlayer());
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName()); if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
} }
@@ -269,17 +269,21 @@ public final class EconomyHandler implements CommandExecutor, Listener {
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */ /** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
void handleMarket(Player player, String[] args, boolean asGuild) { void handleMarket(Player player, String[] args, boolean asGuild) {
if (asGuild && args.length >= 2 && args[0].equalsIgnoreCase("buy")) { if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
if (!asGuild && !player.hasPermission("scopenet.command.market.buy")) {
player.sendMessage(ChatColor.RED + "You do not have permission to buy from the market.");
return;
}
long listing; long listing;
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) { try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
player.sendMessage(ChatColor.RED + "Usage: /guild market buy <listing number> (shown as Listing #n in /market)"); player.sendMessage(ChatColor.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as Listing #n in /market)");
return; return;
} }
JsonObject payload = new JsonObject(); JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listing); payload.addProperty("listing_id", listing);
payload.addProperty("buyer_uuid", player.getUniqueId().toString()); payload.addProperty("buyer_uuid", player.getUniqueId().toString());
payload.addProperty("buyer_name", player.getName()); payload.addProperty("buyer_name", player.getName());
payload.addProperty("as_guild", true); if (asGuild) payload.addProperty("as_guild", true);
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null); operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
return; return;
} }
@@ -93,3 +93,12 @@ integrations:
spark: spark:
enabled: true enabled: true
report-interval-seconds: 30 report-interval-seconds: 30
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
# are unaffected. Display only: everything the client does is an ordinary command.
# ==============================================================================
clientlink:
enabled: true
+2
View File
@@ -10,6 +10,8 @@ include 'common', 'api', 'paper'
// Build loaders separately: Forge and Loom use different Gradle generations. // Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric' if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge' if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
// The optional client mod (HUD, claim overlay, shop and market windows). Fabric 1.20.1; build with -Ploader=fabric-client.
if (providers.gradleProperty('loader').orNull == 'fabric-client') include 'fabric-client'
def mcVer = (providers.gradleProperty('mcVersion').orNull ?: startParameter.projectProperties['mcVersion'] ?: '').toString() def mcVer = (providers.gradleProperty('mcVersion').orNull ?: startParameter.projectProperties['mcVersion'] ?: '').toString()
if (mcVer.startsWith('26.') || mcVer == '26.x') { if (mcVer.startsWith('26.') || mcVer == '26.x') {
if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle' if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'