From 2bcdf26351e2b334dc2e18144c3908c6988c9944 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:38:18 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- docs/fabric.md | 56 +++++ docs/server-integration.md | 4 +- examples/fabric-client/scopenet-client.json | 27 +-- examples/fabric-server/scopenet.properties | 40 ++-- examples/paper/config.yml | 9 + .../java/net/scopenet/core/ClientLink.java | 30 +++ .../test/java/net/scopenet/core/LinkTest.java | 11 + integrations/fabric-client/build.gradle | 16 ++ .../net/scopenet/client/ClientConfig.java | 105 +++++++++ .../java/net/scopenet/client/ClientState.java | 124 ++++++++++ .../main/java/net/scopenet/client/Link.java | 59 +++++ .../net/scopenet/client/ScopenetClient.java | 47 ++++ .../src/main/java/net/scopenet/client/Ui.java | 58 +++++ .../net/scopenet/client/hud/ClaimBorders.java | 49 ++++ .../java/net/scopenet/client/hud/Hud.java | 60 +++++ .../net/scopenet/client/hud/HudWidget.java | 24 ++ .../java/net/scopenet/client/hud/Widgets.java | 109 +++++++++ .../client/screen/HudEditorScreen.java | 120 ++++++++++ .../scopenet/client/screen/MarketScreen.java | 118 ++++++++++ .../scopenet/client/screen/MenuScreen.java | 55 +++++ .../client/screen/SettingsScreen.java | 110 +++++++++ .../scopenet/client/screen/ShopScreen.java | 92 ++++++++ .../assets/scopenet-client/lang/en_us.json | 5 + .../src/main/resources/fabric.mod.json | 10 + integrations/fabric/build.gradle | 7 + .../net/scopenet/fabric/ScopenetFabric.java | 13 +- .../fabric/src/main/resources/fabric.mod.json | 3 +- .../scopenet/fabric/api/ScopenetEconomy.java | 60 +++++ .../fabric/features/FabricConfig.java | 47 ++++ .../fabric/features/FabricFeatures.java | 214 ++++++++++++++++++ .../scopenet/fabric/features/FabricItems.java | 34 +++ .../fabric/features/FabricPlatform.java | 75 ++++++ .../fabric/features/FabricPlayer.java | 63 ++++++ .../fabric/features/LuckPermsLookup.java | 21 ++ .../net/scopenet/fabric/features/Perms.java | 36 +++ .../fabric/features/PlaceholderApiModule.java | 43 ++++ .../fabric/features/ScopenetCommand.java | 55 +++++ .../java/net/scopenet/minecraft/Bridge.java | 7 + .../net/scopenet/paper/ClientLinkBridge.java | 108 +++++++++ .../net/scopenet/paper/ScopenetPlugin.java | 9 +- .../paper/commands/EconomyHandler.java | 10 +- .../paper/src/main/resources/config.yml | 9 + integrations/settings.gradle | 2 + 43 files changed, 2112 insertions(+), 42 deletions(-) create mode 100644 docs/fabric.md create mode 100644 integrations/fabric-client/build.gradle create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/ClientConfig.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/Link.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/ScopenetClient.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/Ui.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/hud/ClaimBorders.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/hud/Hud.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/hud/HudWidget.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/hud/Widgets.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/screen/HudEditorScreen.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/screen/MarketScreen.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/screen/MenuScreen.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/screen/SettingsScreen.java create mode 100644 integrations/fabric-client/src/main/java/net/scopenet/client/screen/ShopScreen.java create mode 100644 integrations/fabric-client/src/main/resources/assets/scopenet-client/lang/en_us.json create mode 100644 integrations/fabric-client/src/main/resources/fabric.mod.json create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/api/ScopenetEconomy.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricItems.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlayer.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/LuckPermsLookup.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/PlaceholderApiModule.java create mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java create mode 100644 integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java diff --git a/docs/fabric.md b/docs/fabric.md new file mode 100644 index 0000000..33d9e7a --- /dev/null +++ b/docs/fabric.md @@ -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. diff --git a/docs/server-integration.md b/docs/server-integration.md index 5009394..401e255 100644 --- a/docs/server-integration.md +++ b/docs/server-integration.md @@ -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. 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. - - 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. - 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`: @@ -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 ``` -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. diff --git a/examples/fabric-client/scopenet-client.json b/examples/fabric-client/scopenet-client.json index 387042f..fb9db12 100644 --- a/examples/fabric-client/scopenet-client.json +++ b/examples/fabric-client/scopenet-client.json @@ -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, "hud": { "scale": 1.0, "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, - "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": { "levelUp": true, "achievements": true, - "friendRequests": true, - "gameInvites": true - }, - "keybinds": { - "openMenu": "key.keyboard.k", - "toggleHud": "key.keyboard.h" + "guild": true, + "errors": true } } diff --git a/examples/fabric-server/scopenet.properties b/examples/fabric-server/scopenet.properties index c1fa5d2..40e9fc5 100644 --- a/examples/fabric-server/scopenet.properties +++ b/examples/fabric-server/scopenet.properties @@ -1,6 +1,7 @@ # 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 -# feature-parity work lands; unknown keys are ignored, so it is safe to keep them here. +# Same panel as the Paper plugin. Sign-in, stats, claims and the Live Map work on every supported Fabric version. +# 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) --------------------------------------------------- # 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). livemap.enabled=true -# --- Essentials [planned] ---------------------------------------------------- -# Homes, warps, /back, /tpa and /rtp. Same defaults as Paper. +# --- Essentials (1.20.1) ----------------------------------------------------- +# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime essentials.max_homes=5 essentials.rtp_radius=2500 essentials.tpa_timeout_seconds=60 -# --- Permissions [planned] --------------------------------------------------- -# Uses the LuckPerms mod when it is installed (nodes are scopenet.command.*, same as Paper). -# 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 (1.20.1) -------------------------------------------------------- +# Shown next to amounts. economy.currency_symbol=$ -# --- Placeholder API [planned] ----------------------------------------------- -# Registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%, %scopenet:playtime%, -# %scopenet:quest_progress% when Placeholder API (Fabric) is installed. +# --- Permissions (1.20.1) ---------------------------------------------------- +# With the LuckPerms mod installed, its answer wins. Use the same nodes as the Paper plugin +# (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 -# --- Client mod link [planned] ----------------------------------------------- -# Send balance, level and guild info to players who have the SCOPENET client mod. +# --- Client mod link (1.20.1) ------------------------------------------------ +# Send balance, level, guild and claim info to players who have the SCOPENET client mod. clientlink.enabled=true diff --git a/examples/paper/config.yml b/examples/paper/config.yml index e30d2b1..d22b626 100644 --- a/examples/paper/config.yml +++ b/examples/paper/config.yml @@ -114,6 +114,15 @@ integrations: enabled: true 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) # scopenet.command. e.g. scopenet.command.home diff --git a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java index d5e1acf..2510fa4 100644 --- a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java +++ b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java @@ -194,6 +194,36 @@ public final class ClientLink { 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. */ public void tick() { for (UUID id : new ArrayList<>(clients)) { diff --git a/integrations/common/src/test/java/net/scopenet/core/LinkTest.java b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java index 18ecdff..11f1beb 100644 --- a/integrations/common/src/test/java/net/scopenet/core/LinkTest.java +++ b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java @@ -114,6 +114,17 @@ class LinkTest { 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 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() { kit.env.features = new Features(true, true, true, true, true, false, "$"); link.receive(alex, msg("{\"t\":\"hello\"}")); diff --git a/integrations/fabric-client/build.gradle b/integrations/fabric-client/build.gradle new file mode 100644 index 0000000..0fa1132 --- /dev/null +++ b/integrations/fabric-client/build.gradle @@ -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' diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/ClientConfig.java b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientConfig.java new file mode 100644 index 0000000..ae684ee --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientConfig.java @@ -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 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 defaults() { + Map 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 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; + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java new file mode 100644 index 0000000..6e40f86 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java @@ -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 market = List.of(); + public volatile List 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 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 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(); } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java b/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java new file mode 100644 index 0000000..4de4979 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java @@ -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])); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/ScopenetClient.java b/integrations/fabric-client/src/main/java/net/scopenet/client/ScopenetClient.java new file mode 100644 index 0000000..05919bc --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/ScopenetClient.java @@ -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(); } + }); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/Ui.java b/integrations/fabric-client/src/main/java/net/scopenet/client/Ui.java new file mode 100644 index 0000000..16528aa --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/Ui.java @@ -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; + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/hud/ClaimBorders.java b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/ClaimBorders.java new file mode 100644 index 0000000..a17b7e1 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/ClaimBorders.java @@ -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(); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Hud.java b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Hud.java new file mode 100644 index 0000000..c0c8665 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Hud.java @@ -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(); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/hud/HudWidget.java b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/HudWidget.java new file mode 100644 index 0000000..20bec8e --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/HudWidget.java @@ -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); +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Widgets.java b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Widgets.java new file mode 100644 index 0000000..b1c1566 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/hud/Widgets.java @@ -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 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); + } + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/screen/HudEditorScreen.java b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/HudEditorScreen.java new file mode 100644 index 0000000..4e970db --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/HudEditorScreen.java @@ -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); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MarketScreen.java b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MarketScreen.java new file mode 100644 index 0000000..1e7a647 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MarketScreen.java @@ -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 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 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); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MenuScreen.java b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MenuScreen.java new file mode 100644 index 0000000..941571c --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/MenuScreen.java @@ -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); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/screen/SettingsScreen.java b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/SettingsScreen.java new file mode 100644 index 0000000..75b3a72 --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/SettingsScreen.java @@ -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 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 apply; + private final boolean percent; + + Slider(int x, int y, int w, String label, double min, double max, double value, boolean percent, Consumer 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); + } +} diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/screen/ShopScreen.java b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/ShopScreen.java new file mode 100644 index 0000000..d74f5ed --- /dev/null +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/screen/ShopScreen.java @@ -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 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(); + } +} diff --git a/integrations/fabric-client/src/main/resources/assets/scopenet-client/lang/en_us.json b/integrations/fabric-client/src/main/resources/assets/scopenet-client/lang/en_us.json new file mode 100644 index 0000000..64923f4 --- /dev/null +++ b/integrations/fabric-client/src/main/resources/assets/scopenet-client/lang/en_us.json @@ -0,0 +1,5 @@ +{ + "key.categories.scopenet": "SCOPENET", + "key.scopenet.menu": "Open SCOPENET menu", + "key.scopenet.toggle_hud": "Show or hide SCOPENET HUD" +} diff --git a/integrations/fabric-client/src/main/resources/fabric.mod.json b/integrations/fabric-client/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..6506041 --- /dev/null +++ b/integrations/fabric-client/src/main/resources/fabric.mod.json @@ -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": "*" } +} diff --git a/integrations/fabric/build.gradle b/integrations/fabric/build.gradle index 60f5e67..2fe3891 100644 --- a/integrations/fabric/build.gradle +++ b/integrations/fabric/build.gradle @@ -8,12 +8,19 @@ if (providers.gradleProperty('verifyMixins').isPresent()) { } java.sourceCompatibility = 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 { minecraft "com.mojang:minecraft:${mc}" mappings loom.officialMojangMappings() modImplementation 'net.fabricmc:fabric-loader:0.16.10' 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.resources.srcDirs += ['../minecraft/src/main/resources'] apply from: '../minecraft/build-info.gradle' diff --git a/integrations/fabric/src/main/java/net/scopenet/fabric/ScopenetFabric.java b/integrations/fabric/src/main/java/net/scopenet/fabric/ScopenetFabric.java index 5f48f02..21d5b18 100644 --- a/integrations/fabric/src/main/java/net/scopenet/fabric/ScopenetFabric.java +++ b/integrations/fabric/src/main/java/net/scopenet/fabric/ScopenetFabric.java @@ -1,11 +1,22 @@ package net.scopenet.fabric; 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 { @Override public void onInitializeServer() { if (Boolean.getBoolean("scopenet.verifyMixins")) 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(); + } } } diff --git a/integrations/fabric/src/main/resources/fabric.mod.json b/integrations/fabric/src/main/resources/fabric.mod.json index 2a22123..547db89 100644 --- a/integrations/fabric/src/main/resources/fabric.mod.json +++ b/integrations/fabric/src/main/resources/fabric.mod.json @@ -6,5 +6,6 @@ "environment": "server", "entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] }, "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": "*" } } diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/api/ScopenetEconomy.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/api/ScopenetEconomy.java new file mode 100644 index 0000000..3bb2778 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/api/ScopenetEconomy.java @@ -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. + * + *
{@code
+ * ScopenetEconomy.get().ifPresent(eco -> eco.balance(uuid).thenAccept(b -> ...));
+ * }
+ */ +public final class ScopenetEconomy { + private static final AtomicReference 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 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 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 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 deposit(UUID player, String name, double amount, String reason) { return change(player, name, Math.abs(amount), reason); } + + public CompletableFuture withdraw(UUID player, String name, double amount, String reason) { return change(player, name, -Math.abs(amount), reason); } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java new file mode 100644 index 0000000..8de8142 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java @@ -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"); } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java new file mode 100644 index 0000000..ee72d63 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java @@ -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 dispatcher) { + List all = new ArrayList<>(commands.all()); + all.add(new ScopenetCommand(this)); + for (CoreCommand command : all) { + List names = new ArrayList<>(); + names.add(command.name()); + names.addAll(command.aliases()); + for (String name : names) { + LiteralArgumentBuilder 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 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 suggest(CommandContext 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 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()); + } + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricItems.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricItems.java new file mode 100644 index 0000000..e71a99d --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricItems.java @@ -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())); + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java new file mode 100644 index 0000000..87872c9 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java @@ -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 player(UUID uuid) { return Optional.ofNullable(server.getPlayerList().getPlayer(uuid)).map(this::wrap); } + + @Override public Optional playerByName(String name) { return Optional.ofNullable(server.getPlayerList().getPlayerByName(name)).map(this::wrap); } + + @Override public Collection online() { + List out = new ArrayList<>(); + for (ServerPlayer p : server.getPlayerList().getPlayers()) out.add(wrap(p)); + return out; + } + + @Override public Optional 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); } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlayer.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlayer.java new file mode 100644 index 0000000..bf86d8b --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlayer.java @@ -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 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); + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/LuckPermsLookup.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/LuckPermsLookup.java new file mode 100644 index 0000000..dd58191 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/LuckPermsLookup.java @@ -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(); + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java new file mode 100644 index 0000000..c0c3df4 --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java @@ -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); + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/PlaceholderApiModule.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/PlaceholderApiModule.java new file mode 100644 index 0000000..1fc747a --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/PlaceholderApiModule.java @@ -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 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; + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java new file mode 100644 index 0000000..f0c86ca --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/ScopenetCommand.java @@ -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 aliases() { return List.of("sn"); } + @Override public String permission() { return "scopenet.command.scopenet"; } + + @Override public List 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."); + } +} diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index e33b288..8ddf3fd 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -16,6 +16,12 @@ public final class Bridge { private static int mapTicks; 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 claimBypass = player -> false; + public static void start(MinecraftServer server) { try { integration = new Integration(Settings.load(Path.of("config/scopenet.properties")), @@ -103,6 +109,7 @@ public final class Bridge { Integration current = integration; if (current == null) return false; if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true; + if (claimBypass.test(player)) return true; String dimension = Compat.dimension(player); ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID()); if (check.allowed()) return true; diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java new file mode 100644 index 0000000..3f16c51 --- /dev/null +++ b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java @@ -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 player(UUID uuid) { return Optional.ofNullable(Bukkit.getPlayer(uuid)).map(PaperPlayer::new); } + @Override public Optional playerByName(String name) { return Optional.ofNullable(Bukkit.getPlayerExact(name)).map(PaperPlayer::new); } + @Override public Collection online() { + List out = new ArrayList<>(); + for (Player p : Bukkit.getOnlinePlayers()) out.add(new PaperPlayer(p)); + return out; + } + @Override public Optional 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 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) { } + } + } +} diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 3edbbb3..6f069ea 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -24,6 +24,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { private EconomyHandler economy; private ScopenetApiImpl api; private CompatManager compat; + private ClientLinkBridge clientLink; @Override public void onEnable() { // 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()); ScopenetApiProvider.register(api); 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); // 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() { if (compat != null) compat.stop(); + if (clientLink != null) clientLink.stop(); if (api != null) { getServer().getServicesManager().unregister(ScopenetApi.class, api); ScopenetApiProvider.unregister(api); @@ -213,6 +219,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { public void leave(PlayerQuitEvent event) { deniedAt.remove(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()); } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java index 851ae4e..dba9176 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java @@ -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). */ 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; try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) { - player.sendMessage(ChatColor.RED + "Usage: /guild market buy (shown as Listing #n in /market)"); + player.sendMessage(ChatColor.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy (shown as Listing #n in /market)"); return; } JsonObject payload = new JsonObject(); payload.addProperty("listing_id", listing); payload.addProperty("buyer_uuid", player.getUniqueId().toString()); 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); return; } diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index 69b1b23..5f1614e 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -93,3 +93,12 @@ integrations: spark: enabled: true 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 diff --git a/integrations/settings.gradle b/integrations/settings.gradle index 973ee14..91a2dcc 100644 --- a/integrations/settings.gradle +++ b/integrations/settings.gradle @@ -10,6 +10,8 @@ include 'common', 'api', 'paper' // Build loaders separately: Forge and Loom use different Gradle generations. if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric' if (providers.gradleProperty('loader').orNull == 'forge') include 'forge' +// 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() if (mcVer.startsWith('26.') || mcVer == '26.x') { if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'