diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index cf14649..84a485e 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -36,8 +36,8 @@ Project architecture is provided via the blpc-overview skill. - Proper use of `ModLog` categories for logging - Trust level / trust action consistency -### Java 17 Syntax (Mandatory) -- All Java 17 features enforced per blpc-overview skill (switch expressions, pattern matching instanceof, `var`, multi-label case) +### Java 25 Syntax (Mandatory) +- All Java 25 features enforced per blpc-overview skill (switch expressions, pattern matching instanceof, `var`, multi-label case) ### Comments & Javadoc - Public API classes/interfaces (`api/` package) have Javadoc with `@param`, `@return`, `@throws` as appropriate diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index ef3aabe..9e52297 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -23,7 +23,7 @@ You receive tasks from the QA lead or the user. Your job is to: ## Key Rules - **Do not edit `build.gradle`** (auto-generated) -- **Java 17 syntax is mandatory** per blpc-overview skill (switch expressions, pattern matching instanceof, `var`) +- **Java 25 syntax is mandatory** per blpc-overview skill (switch expressions, pattern matching instanceof, `var`) - New network messages: append (never insert). - **C→S** — IMessage + handler both in `common/network/`. Append to `ModNetwork.init()` C→S block. - **S→C** — IMessage in `common/network/` (no `@SideOnly` types in bytecode!). Handler in `client/network/ClientHandler.java` with `@SideOnly(Side.CLIENT)`. Append to **both** `ModNetwork.CLIENT_BOUND_MESSAGES` and `ClientPacketHandlers.installAll()` in identical order. diff --git a/.claude/skills/blpc-overview/SKILL.md b/.claude/skills/blpc-overview/SKILL.md index b3bcea8..e198081 100644 --- a/.claude/skills/blpc-overview/SKILL.md +++ b/.claude/skills/blpc-overview/SKILL.md @@ -17,10 +17,11 @@ RetroFuturaGradle (RFG) with GTNH Buildscripts. **Do not edit `build.gradle`** ( | ModularUI | GUI framework | Yes | | BetterQuesting Unofficial | Party system backend (when present) | Optional (module) | | JourneyMap API | Overlay integration | Optional | +| JourneyMap mod jar (`compileOnly`, not runtime-required) | Compile-time target for `WaypointStoreMixin`'s internal (non-API) class references | Optional | -## Java 17 Syntax (Mandatory) +## Java 25 Syntax (Mandatory) -Jabel (`enableModernJavaSyntax = true`) compiles Java 17 features to JVM 8 bytecode. **目的:** NPE削減(pattern matching で安全なキャスト)とコード量削減(switch expressions で冗長なbreak/castを排除)。 +Jabel (`enableModernJavaSyntax = true`) compiles Java 25 features to JVM 8 bytecode. **Purpose:** fewer NPEs (safe casts via pattern matching) and less code (switch expressions drop redundant `break`/casts). | Feature | Requirement | Example | |---|---|---| @@ -48,6 +49,8 @@ Modules are discovered at FML Construction via `@TModule` annotation scanning. T Party management is abstracted via `IPartyProvider`, allowing transparent switching between self-managed parties and BQu's party system: - **`api/party/IPartyProvider`** — Full interface with query methods (`areInSameParty`, `getPartyName`, `getPartyMembers`, `getRole`; plus `default` query methods `findByName`, `allPartyNames`, `pendingInvitesFor`) and mutation methods (`createParty`, `disbandParty`, `renameParty`, `invitePlayer`, `acceptInvite`, `kickOrLeave`, `changeRole`, `syncToAll`). Most mutation methods identify the party via the acting player's UUID. Exception: `acceptInvite(player, partyId)` requires an explicit partyId since it targets a different party. Addons should query via `api/util/PartyQueryUtil` rather than the raw interface. + - **`getPartyId(UUID)`** and **`getEffectiveParty(UUID)`** — `default` methods returning `null`, added specifically so server-side authoritative code never reads `PartyManagerData` directly (which only reflects BLPC's own invite/accept/create flow — a BQu member added purely through BQu's own party UI has no record there). `getPartyId` returns just a stable storage key (used by `WaypointManagerData`, login sync); `getEffectiveParty` returns a fully-populated `Party` (members, trust levels, allies/enemies, limits) for **`ChunkProtectionHandler`** (trust checks), **`ClaimChunk.Handler`** (additive claim/force-load limits), **`ChunkTransitHandler`** (relation resolution for notifications/area effects), and the login/logout force-load re-sync in `PlayerLoginHandler`/`CoreEventHandler` — call `PartyProviderRegistry.get().getEffectiveParty(playerId)` from new code in these areas instead of `PartyManagerData.getInstance().getPartyByPlayer(playerId)`. `DefaultPartyProvider` just delegates to `PartyManagerData`; `BQuPartyProvider` builds a merged `Party` via its private `buildMergedParty(DBEntry)` (live BQu membership + settings copied from whichever member has a BLPC-side record, preferring the owner's) — the same helper backs `serializeForClient()`'s per-party client-sync view, so both stay consistent. Party **settings mutations** (`PartyAction`'s `ACTION_SET_TRUST_LEVEL`, `ACTION_SET_COLOR`, ally/enemy, etc.) intentionally keep reading/writing `PartyManagerData` directly — those settings are BLPC-only concepts BQu has no equivalent for, and are exactly what a BQu-linked party's `Party` record exists to store. + - **`isLinkedParty(UUID)`** — `default` method returning `false`, used for **routing** (which provider handles a player's mutating action) instead of the `PartyManagerData.bquLinkedPlayers` per-player flag. That flag is only ever set for the members present at the moment an OWNER runs `ACTION_TOGGLE_BQU_LINK`; a player who joins the same (already-linked) BQu party afterward — normally through BQu's own party screen — never gets it, so flag-based routing kept sending their actions to the self-managed provider, and `PartyAction`'s `getOrCreateSelfParty` would then silently create a disconnected personal party for them on any settings change. `BQuPartyProvider.isLinkedParty` instead checks the player's *current* BQu party for any member with the flag set — recognizing new joiners immediately. `PartyAction.Handler.dispatch()`, `WaypointAction.Handler`, and `BLPCCommandHelper.activeProviderFor` all call `provider.isLinkedParty(playerId)` rather than `PartyManagerData.isBQuLinked(playerId)` directly. `BQuPartyProvider.serializeForClient()`'s `bquLinked` NBT list is likewise built from live `bquMembers` (every member of every linked party), not forwarded from the stale flag set, so the client's link-state UI matches. - **`api/party/PartyProviderRegistry`** — Priority-based registry for the active provider. Constants: `PRIORITY_LOW=-100`, `PRIORITY_DEFAULT=0`, `PRIORITY_HIGH=100`. Higher priority wins; equal priority logs a warning and accepts the new provider (last-write-wins at tie); lower priority is silently ignored. Use `register(provider, priority)`. - **`common/party/DefaultPartyProvider`** — Self-managed implementation backed by `PartyManagerData`. Registered by `CoreModule` at `PRIORITY_DEFAULT`. - **`integration/bqu/BQuPartyProvider`** — BQu implementation that directly operates on BQu's `PartyManager`, `PartyInvitations`, and `NetPartySync`, with fallback to `DefaultPartyProvider` for players not in a BQu party. Registered by `BQuModule` at `PRIORITY_HIGH`, replacing the default provider when BQu is present — no data duplication. @@ -67,16 +70,18 @@ Party management is abstracted via `IPartyProvider`, allowing transparent switch - **`api/`** — Public, addon-facing surface. `BLPCAPI` (façade/index), `modules/` (module framework SPI), `party/` (party backend SPI + domain types — `IPartyProvider`, `PartyProviderRegistry` with priority registration, `unregister()`/`getRegisteredPriority()` for diagnostics/reset, `registerNativeScreenOpener`/`unregisterNativeScreenOpener`/`hasNativeScreen`; **domain types**: `Party`, `PartyRole`, `TrustLevel`, `TrustAction`, `RelationType`), `event/` (`ChunkModifiedEvent`; `PartyEvent` — Pre/Post lifecycle hierarchy: cancelable `Pre.Created`/`Pre.Disbanded` veto mutations before they occur; informational `Post.Created`/`Post.Disbanded`/`Post.MemberJoined`/`Post.MemberLeft`/`Post.RoleChanged` fire after success), `util/` (`Mods`, `ModUtility`, `PartyQueryUtil` — addon-safe query façade delegating to the active `IPartyProvider`; `EnumUtils.parseOrDefault(Class, name, default)` — shared `valueOf`-or-fallback used by `TrustLevel.fromName`, `PartyRole.fromName`, `RelationType.fromName`; reach for this instead of writing another try/catch `valueOf`), `integration/` (`IntegrationPanelRegistry` — registry of per-mod settings panels for the Addons hub, mirroring the concrete `integration/` package below; see `client/gui/AddonsPanel` below). - **`common/party/`** — Party infrastructure: `PartyManagerData`, `DefaultPartyProvider`, `ClientPartyCache`. Domain types (`Party`, `PartyRole`, `TrustLevel`, `TrustAction`, `RelationType`) live in `api/party/`. - **`common/chunk/`** — Claim data: `ChunkManagerData` (per-player and per-party claim/force-load counts funnel through a private `countMatching(Predicate)`; `ClaimChunk.Handler.isLimitReached(...)` mirrors this shape one level up, taking per-player vs. per-party count/max accessors as lambdas so `isClaimLimitReached`/`isForceLoadLimitReached` share one implementation), `ClaimedChunkData`, `ClientClaimCache` (client-side cache — named to mirror `common/party/ClientPartyCache`'s pattern), `TicketManager`. +- **`common/waypoint/`** — Party-shared JourneyMap waypoint data (server + client): `PartyWaypointData` (value type), `WaypointManagerData` (server-side singleton store, persisted by `BLPCSaveHandler`), `ClientWaypointCache` (client-side mirror + change listeners, same shape as `ClientPartyCache`). See "JourneyMap Waypoint Team Sync" below. - **`common/network/`** — IMessage contracts only (no client-only references): - - C→S: `ClaimChunk` (with inner `Handler`), `PartyAction` (with inner `Handler` — see below; same nested-handler convention as `ClaimChunk`, just larger). - - S→C: `SyncClaims`, `SyncAllClaims`, `SyncConfig`, `PartySync`, `ClientNotify`. Each is a pure data container with getters; no inner `Handler`. `ClientNotify` is a discriminator-multiplexed packet that carries every transient client toast (chunk transit, party event, claim limit) through a single wire ID — it does **not** hold `PartyAction`'s handler; that lives in `PartyAction.Handler`. - - `NbtMessage` — abstract base for messages whose entire payload is one `NBTTagCompound` (`data` field + getter + `readTag`/`writeTag`). `PartySync` and `SyncAllClaims` extend it; future NBT-payload messages should too. - - `ModNetwork` — channel registration (side-aware). `NoOpHandler` — server-side fallback so S→C discriminators stay valid for outbound sends. `PlayerLoginHandler` — login sync. -- **`client/network/`** — All S→C handlers (`@SideOnly(Side.CLIENT)`), one class per top-level wire packet: `SyncClaimsClientHandler`, `SyncAllClaimsClientHandler`, `SyncConfigClientHandler`, `PartySyncClientHandler`, `ClientNotifyClientHandler` (dispatches by `ClientNotify.getKind()` to the matching `BLPCToast` builder). Every one of them extends `MainThreadMessageHandler`, whose `final onMessage` schedules `handleOnMainThread(msg)` onto `Minecraft.addScheduledTask` — implementations only override `handleOnMainThread`, never re-implement the scheduling hop. `ClientPacketHandlers` is a side-aware SPI installer (intentionally **not** `@SideOnly`) referenced by `ModNetwork`. -- **`client/gui/`** — ModularUI screens only. `Screens` = the single catalog of every GUI + its open/build entry points (`openMap()`, `partyMain(...)`; RecipeMaps analog); `BLPCGuiTextures` = shared reusable `IDrawable`s (`DIVIDER`, `MAP_BACKGROUND`, `MAP_BORDER`) + `ICON_*` constants that reuse ModularUI's built-in `GuiTextures` icon atlas (`CLOSE`/`REFRESH`/`REMOVE` — no custom art; chunk-map tool buttons use these). Drawables are shared instances (a `Rectangle` only reads its fields at draw time) — never inline `new Rectangle().color(...)` in screen code, add it here. `BLPCColors` = semantic party/map palette, `GuiColors` = fixed vanilla-context ARGB; `BLPCToast` = vanilla toast notification; `ChunkMapScreen`/`ChunkMapWidget`; `PlayerFaceDrawable`; party panels in `party/` subpackage; reusable widgets in `party/widget/` (`ConfirmDialog`, `InputDialog`, `LiveSearchableList`). Map pixel math derives from `ChunkMapRenderer.CHUNK_BLOCKS` (16 blocks/chunk — the single source for the recurring `% 16` / `/ 16` calculations). + - C→S: `ClaimChunk` (with inner `Handler`), `PartyAction` (with inner `Handler` — see below; same nested-handler convention as `ClaimChunk`, just larger), `WaypointAction` (with inner `Handler` — same convention, enforces party-OWNER-only mutation). + - S→C: `SyncClaims`, `SyncAllClaims`, `SyncConfig`, `PartySync`, `ClientNotify`, `WaypointSync`, `SyncAllWaypoints`. Each is a pure data container with getters; no inner `Handler`. `ClientNotify` is a discriminator-multiplexed packet that carries every transient client toast (chunk transit, party event, claim limit) through a single wire ID — it does **not** hold `PartyAction`'s handler; that lives in `PartyAction.Handler`. + - `NbtMessage` — abstract base for messages whose entire payload is one `NBTTagCompound` (`data` field + getter + `readTag`/`writeTag`). `PartySync`, `SyncAllClaims`, and `SyncAllWaypoints` extend it; future NBT-payload messages should too. + - `ModNetwork` — channel registration (side-aware). `NoOpHandler` — server-side fallback so S→C discriminators stay valid for outbound sends. `PlayerLoginHandler` — login sync (claims, parties, and — since the waypoint feature — the full party waypoint snapshot via `SyncAllWaypoints`). +- **`client/network/`** — All S→C handlers (`@SideOnly(Side.CLIENT)`), one class per top-level wire packet: `SyncClaimsClientHandler`, `SyncAllClaimsClientHandler`, `SyncConfigClientHandler`, `PartySyncClientHandler`, `ClientNotifyClientHandler` (dispatches by `ClientNotify.getKind()` to the matching `BLPCToast` builder), `WaypointSyncClientHandler`, `SyncAllWaypointsClientHandler` (bulk-loads via `ClientWaypointCache.loadAll(...)`, not per-entry `update()` — see waypoint section below for why). Every one of them extends `MainThreadMessageHandler`, whose `final onMessage` schedules `handleOnMainThread(msg)` onto `Minecraft.addScheduledTask` — implementations only override `handleOnMainThread`, never re-implement the scheduling hop. `ClientPacketHandlers` is a side-aware SPI installer (intentionally **not** `@SideOnly`) referenced by `ModNetwork`. +- **`client/gui/`** — ModularUI screens only. `Screens` = the single catalog of every GUI + its open/build entry points (`openMap()`, `partyMain(...)`; RecipeMaps analog); `BLPCGuiTextures` = shared reusable `IDrawable`s (`DIVIDER`, `MAP_BACKGROUND`, `MAP_BORDER`) + `ICON_*` constants that reuse ModularUI's built-in `GuiTextures` icon atlas (`CLOSE`/`REFRESH`/`REMOVE` — no custom art; chunk-map tool buttons use these). Drawables are shared instances (a `Rectangle` only reads its fields at draw time) — never inline `new Rectangle().color(...)` in screen code, add it here. `BLPCColors` = semantic party/map palette, `GuiColors` = fixed vanilla-context ARGB; `BLPCToast` = vanilla toast notification; `ProtectionStatusHud` = brief claimed-chunk indicator (see "Protection Status HUD" below); `ChunkMapScreen`/`ChunkMapWidget`; `PlayerFaceDrawable`; party panels in `party/` subpackage; reusable widgets in `party/widget/` (`ConfirmDialog`, `InputDialog`, `LiveSearchableList`). Map pixel math derives from `ChunkMapRenderer.CHUNK_BLOCKS` (16 blocks/chunk — the single source for the recurring `% 16` / `/ 16` calculations). - **`client/gui/AddonsPanel`** — Addons hub (single class directly under `client/gui/`, not a subpackage — it's one small screen, not a feature area like `party/`). Searchable via `PartyWidgets.finalizeSearchableList`; lists the available entries from `api/integration/IntegrationPanelRegistry` (lives in `api/` so third-party integrations register without depending on `client.gui` internals; each integration module registers one entry from its client-side init via a lazy method reference, mirroring `PartyProviderRegistry.registerNativeScreenOpener` — no `@SideOnly` on the registry, client-only-ness lives in the lambdas); opened from `MainPanel` when `IntegrationPanelRegistry.hasAvailable()`. The per-mod panels live in their integration packages, named `SettingsPanel` — not `AddonPanel` — since they're just each mod's settings screen, not an "addon" concept in their own right (`integration/jmap/JMapSettingsPanel`, `integration/bqu/BQuSettingsPanel`). BQu's link/unlink toggle and native-manager shortcut live in `BQuSettingsPanel` (registered when `PartyProviderRegistry.hasNativeScreen()`), not `SettingsPanel`'s Party Info tab. - **`client/input/`** — `KeyInputHandler` (keybind registration; routes key presses to `Screens`). Single keybind: open chunk map (`M`). - **`client/map/`** — Async chunk rendering, texture caching, claim overlay. +- **`client/cache/`** — `ClientCacheKey` (derives a filesystem-safe identifier for the current connection — singleplayer save folder or multiplayer server IP) + `ClientCachePersistence` (debounced NBT snapshot of `ClientClaimCache`/`ClientPartyCache` to `/blpc/cache//{claims,parties}.dat`, so the map/party UI shows last-known state immediately after reconnecting instead of an empty screen). Registered/loaded from `CoreEventHandler.ClientHandler` on `ClientConnectedToServerEvent`/`ClientDisconnectionFromServerEvent` — both hop onto `Minecraft.addScheduledTask` first, since Forge posts those events from the Netty I/O thread and `ClientClaimCache`/`ClientPartyCache` are plain non-thread-safe collections. NBT snapshotting always happens on the main thread; only the actual file write is handed to a background executor. ## Network Layer Architecture @@ -100,11 +105,14 @@ The network layer is split along the physical side boundary so that loading a cl |---|---|---|---| | 0 | C→S | `ClaimChunk` | `ClaimChunk.Handler` | | 1 | C→S | `PartyAction` (multiplexed) | `PartyAction.Handler` | -| 2 | S→C | `SyncClaims` | `SyncClaimsClientHandler` | -| 3 | S→C | `SyncAllClaims` | `SyncAllClaimsClientHandler` | -| 4 | S→C | `SyncConfig` | `SyncConfigClientHandler` | -| 5 | S→C | `PartySync` | `PartySyncClientHandler` | -| 6 | S→C | `ClientNotify` (multiplexed) | `ClientNotifyClientHandler` | +| 2 | C→S | `WaypointAction` (multiplexed) | `WaypointAction.Handler` | +| 3 | S→C | `SyncClaims` | `SyncClaimsClientHandler` | +| 4 | S→C | `SyncAllClaims` | `SyncAllClaimsClientHandler` | +| 5 | S→C | `SyncConfig` | `SyncConfigClientHandler` | +| 6 | S→C | `PartySync` | `PartySyncClientHandler` | +| 7 | S→C | `ClientNotify` (multiplexed) | `ClientNotifyClientHandler` | +| 8 | S→C | `WaypointSync` | `WaypointSyncClientHandler` | +| 9 | S→C | `SyncAllWaypoints` | `SyncAllWaypointsClientHandler` | ### Discriminator-multiplexed packets (preferred for new operations) @@ -112,7 +120,8 @@ Two packets carry their own internal discriminator so adding new operations does not require a new top-level wire ID: - **`PartyAction`** (C→S, ID 1) — `int action` + `String stringArg`. ~22 party operations. -- **`ClientNotify`** (S→C, ID 6) — `int kind` + per-kind payload. Three kinds today (`KIND_CHUNK_TRANSIT`, `KIND_PARTY_EVENT`, `KIND_CLAIM_FAILED`) covering every BLPC toast. +- **`WaypointAction`** (C→S, ID 2) — `int action` (`ACTION_ADD_OR_UPDATE`/`ACTION_REMOVE`) + waypoint fields. See "JourneyMap Waypoint Team Sync" below. +- **`ClientNotify`** (S→C, ID 7) — `int kind` + per-kind payload. Three kinds today (`KIND_CHUNK_TRANSIT`, `KIND_PARTY_EVENT`, `KIND_CLAIM_FAILED`) covering every BLPC toast. Append-only: existing constants are part of the on-wire format. Do not renumber. @@ -127,7 +136,7 @@ Append-only: existing constants are part of the on-wire format. Do not renumber. `PartyAction` multiplexes ~22 party operations through an `int action` discriminator + `String stringArg`. The server-side `PartyAction.Handler` (nested in `PartyAction.java`, same convention as `ClaimChunk.Handler`) has one private static method per `ACTION_*` constant. Per-request state (player, args, providers, BQu link state, deferred notifications) lives in a private `ActionContext` holder passed to each method. -**Authorization invariant:** `playerBQuLinked` and `activeProvider` are re-derived from `PartyManagerData.isBQuLinked` on every request — never trusted from the client. Mutating actions go through `getAdminParty()` / `getOrCreateSelfParty()` which enforce role checks server-side. Simple settings actions wrap the ADMIN+ gate via `onAdminParty(c, Predicate)` — return `false` from the predicate to fail the action. +**Authorization invariant:** `playerBQuLinked` and `activeProvider` are re-derived from `IPartyProvider#isLinkedParty` on every request — never trusted from the client, and a *live* check against current party membership rather than a stale per-player flag (see `isLinkedParty` above). Mutating actions go through `getAdminParty()` / `getOrCreateSelfParty()` which enforce role checks server-side. Simple settings actions wrap the ADMIN+ gate via `onAdminParty(c, Predicate)` — return `false` from the predicate to fail the action. `disbandParty()` and `toggleBQuLink()` resolve the acting player's party/role via `c.provider.getEffectiveParty(...)` / `c.provider.getRole(...)` rather than a raw `PartyManagerData` lookup, for the same reason. **Failure → rollback:** `dispatch()` calls `provider.syncToAll()` on success; on failure it sends `provider.syncToPlayer(actor)` (a single-player sync) so the actor's optimistic UI mutation is corrected (`TOGGLE_BQU_LINK` is the exception — it broadcasts on failure too, since provider state may have drifted). `joinFreeParty` / `acceptInvite` also push an `EVENT_PARTY_FULL` or `EVENT_JOIN_FAILED` toast on their respective failure paths so a click is never silent. @@ -156,9 +165,12 @@ world/betterlink/pc/ ├── parties/ │ ├── 0.dat # one compressed NBT file per party (keyed by partyId) │ └── ... -└── claims/ - ├── global.dat # claims belonging to players with no party - ├── 0.dat # claims belonging to members of party 0 +├── claims/ +│ ├── global.dat # claims belonging to players with no party +│ ├── 0.dat # claims belonging to members of party 0 +│ └── ... +└── waypoints/ + ├── 0.dat # shared JourneyMap waypoints for party 0 (only written if non-empty) └── ... ``` @@ -203,7 +215,7 @@ The Settings panel cycles each action through `NONE -> ALLY -> MEMBER`. Addition | `blpc.party.members` | `MembersPanel.java` | Member list | | `blpc.party.moderators` | `ModeratorsPanel.java` | Moderator promote/demote | | `blpc.party.addons` | `client/gui/AddonsPanel.java` | Addons hub — searchable list of available per-mod settings panels | -| `blpc.party.addons.journeymap` | `integration/jmap/JMapSettingsPanel.java` | JourneyMap claim-overlay toggle (+ future waypoint sharing) | +| `blpc.party.addons.journeymap` | `integration/jmap/JMapSettingsPanel.java` | JourneyMap claim-overlay toggle + team waypoint-sharing toggle | | `blpc.party.addons.bqu` | `integration/bqu/BQuSettingsPanel.java` | BQu link/unlink toggle + native party manager shortcut | | `blpc.party.dialog.disband` | MainPanel (inline `ConfirmDialog`) | Disband confirmation | | `blpc.party.dialog.transfer` | `client/gui/party/TransferOwnerPanel.java` | Transfer ownership | @@ -396,8 +408,33 @@ Uses MixinBooter (`ILateMixinLoader`) for conditional late-stage injection: - **`BLPCMixinLoader`** — Loads mixin configs conditionally based on mod presence. - **`NetPartyActionMixin`** — Injects into BQu's `NetPartyAction.deleteParty()` to auto-unlink all affected players from BQu in BLPC's `PartyManagerData`. Prevents orphaned BQu links. +- **`mixins/journeymap/WaypointStoreMixin`** (client-only) — Injects into JourneyMap's internal (non-API) `journeymap.client.waypoint.WaypointStore` to detect local waypoint add/edit/remove, since the public JourneyMap API has no change-notification hook for this. `@Inject(method = "save", at = @At("RETURN"))` and `@Inject(method = "remove", at = @At("HEAD"))` forward to `JMapWaypointOutgoing`. Deliberately does **not** hook `WaypointStore`'s add path (startup load would look identical to a real add and cause spurious network traffic). Because this reaches into JourneyMap's non-API internals, it's inherently more fragile across JourneyMap versions than the rest of the (API-based) `integration/jmap` code — see "JourneyMap Waypoint Team Sync" below. + +Configs: `src/main/resources/mixins.blpc.betterquesting.json`, `src/main/resources/mixins.blpc.journeymap.json` (`client: ["WaypointStoreMixin"]`, no `server` mixins — JourneyMap itself is client-only). `dependencies.gradle` adds `compileOnly rfg.deobf(...)` for JourneyMap's mod jar (not just the API) so the Mixin's target classes resolve at compile time. + +## JourneyMap Waypoint Team Sync + +Party-owned JourneyMap waypoints are mirrored to every online party member's local map, so a party sees one shared set of markers (e.g. base, farm, portal) instead of each member maintaining their own. Gated by `JMapClientConfig.isWaypointSharingEnabled()` (per-client toggle in `JMapSettingsPanel`) and, structurally, by whether the Mixin config loaded at all (`Mods.Names.JOURNEY_MAP` present). + +**Permission model:** only the party **OWNER** may add/edit/remove shared waypoints; regular members are view-only. This is enforced authoritatively server-side in `WaypointAction.Handler` — a non-owner's action is rejected and the server sends back the pre-existing server-side state for that waypoint (or a `WaypointSync.remove` if it didn't exist) so the sender's local JourneyMap store snaps back to the authoritative state instead of silently keeping the rejected local edit. `JMapWaypointOutgoing.isPartyOwner()` mirrors this client-side purely to avoid pointless traffic/rollback flicker for non-owners — it is not itself a security boundary. + +**Outgoing flow (owner's client → server):** +1. `WaypointStoreMixin` detects a local `save`/`remove` on JourneyMap's internal `WaypointStore` and forwards to `JMapWaypointOutgoing`. +2. `JMapWaypointOutgoing` filters out: remote-echoed changes (`applyingRemoteChange` flag, set while `JMapWaypointSyncHandler` is writing incoming data — prevents feedback loops), `Waypoint.Type.Death` waypoints, non-owners, and sharing-disabled clients. +3. **Save/remove debounce**: JourneyMap's waypoint editor always does `remove(original)` then `save(edited)` even for a pure edit of an existing waypoint. A detected remove is held in `pendingRemoveId` until end-of-tick (`TickEvent.ClientTickEvent`, static-registered on the class) rather than sent immediately; if a `save` for the same id arrives first, the pending remove is cleared and only the update is sent. Without this, every edit would emit a spurious delete-then-recreate on every other member's map. +4. Sends `WaypointAction.addOrUpdate(...)` / `.remove(...)` (C→S, ID 2) to the server. + +**Server (`WaypointAction.Handler`):** resolves the acting player's party via `IPartyProvider.getPartyId(UUID)` (see below), validates (`waypointId`/`name` length caps, `MAX_WAYPOINTS_PER_PARTY = 200`), authorizes (OWNER-only, with rollback on rejection as described above), applies the change to `WaypointManagerData`, persists via `BLPCSaveHandler`, and broadcasts a `WaypointSync` diff (S→C, ID 8) to every **other** online party member (the actor already has the change applied locally). + +**Incoming flow (other members / full login sync):** `WaypointSyncClientHandler` (single-waypoint diff) and `SyncAllWaypointsClientHandler` (full snapshot, sent on login via `PlayerLoginHandler`) write into `ClientWaypointCache`, whose change listener (`JMapWaypointSyncHandler`) rebuilds the local JourneyMap `WaypointStore` entries under `applyingRemoteChange = true` so the mirrored writes don't re-trigger `WaypointStoreMixin`. `SyncAllWaypointsClientHandler` uses `ClientWaypointCache.loadAll(...)` (replace-all + fire listeners once) rather than looping `update()` per waypoint — the latter would re-run the full JourneyMap mirror rebuild once per waypoint on login, an O(n²) cost for a party with many waypoints. + +**Deterministic IDs:** a shared waypoint's key is JourneyMap's own `Waypoint.getId()`, which for a waypoint built via `journeymap.client.api.display.Waypoint(BLPC_MODID, waypointId, ...)` always resolves to `"blpc:" + waypointId` (from JourneyMap's `Waypoint.getGuid()` = `origin + ":" + displayId`). `JMapWaypointSyncHandler.applyToJourneyMap()` matches on `Tags.MODID.equals(wp.getOrigin())` to find/clean up only BLPC-mirrored entries, without needing a separate id-mapping table. + +**`IPartyProvider.getPartyId(UUID)`:** a `default` method returning `null`, added specifically so waypoint code (and any future per-party server storage) can resolve a stable party identifier without depending on a `Party` object existing. `DefaultPartyProvider` derives it from its own `Party.getPartyId()`; `BQuPartyProvider` derives it from BQu's own integer party id via `Party.uuidFromIntId(...)` so it's identical for every member even if no BLPC-side `Party` record has ever been created for that BQu party (a real bug found in earlier iterations — resolving the acting player's `Party` object directly could diverge between members before the BQu link created BLPC-side shadow records). -Config: `src/main/resources/mixins.blpc.betterquesting.json`. +**Persistence:** `common/waypoint/WaypointManagerData` (server-side singleton, `Map>`, `getWaypoints`/`getAllForSave` return unmodifiable views) is saved/loaded by `BLPCSaveHandler` under `world/betterlink/pc/waypoints/.dat`, one file per party with any waypoints (mirrors the `parties/`/`claims/` layout). `WaypointManagerData.removeParty(partyId)` is called from `PartyAction`'s disband path so a disbanded party's waypoints don't linger. + +**Key classes:** `common/waypoint/PartyWaypointData` (value type), `WaypointManagerData` (server store), `ClientWaypointCache` (client mirror + change listeners); `common/network/message/WaypointAction` (C→S, with nested `Handler`), `WaypointSync` (S→C diff), `SyncAllWaypoints` (S→C snapshot, extends `NbtMessage`); `integration/jmap/JMapWaypointOutgoing` (local-change detector), `JMapWaypointSyncHandler` (remote-change applier); `mixins/journeymap/WaypointStoreMixin`. ## Server Configuration (ModConfig) @@ -416,11 +453,13 @@ Uses nested subcategories via `@Config.LangKey` (`config.blpc.`). Acce | `additiveLimits` | boolean | true | Party claim limit = sum of each member's individual limit | | `allowOfflineChunkLoading` | boolean | true | Keep force-loaded chunks active when all party members are offline | +**Party required to claim:** `ClaimChunk.Handler.isPartyMissing` rejects a brand-new claim (both `MODE_CLAIM` and the fresh-claim branch of `MODE_TOGGLE_FORCE`) unless `PartyProviderRegistry.get().getPartyId(playerId) != null` — chunk protection is a party-sharing feature, not a solo-player one, so a player must first create/join a party (or, in singleplayer, rely on `ModConfig.party.autoCreatePartySingleplayer`). Rejection sends `ClientNotify.claimFailed(REASON_NO_PARTY, 0, 0)` → `blpc.toast.no_party`. Already-claimed chunks are unaffected (unclaim/toggle-force on an *existing* claim never re-checks this). + **Party** (`ModConfig.party`) | Option | Type | Default | Description | |---|---|---|---| -| `autoCreatePartySingleplayer` | boolean | false | Auto-create party in singleplayer | +| `autoCreatePartySingleplayer` | boolean | true | Auto-create party in singleplayer | **Server Party** (`ModConfig.serverParty`) @@ -438,6 +477,14 @@ Uses nested subcategories via `@Config.LangKey` (`config.blpc.`). Acce |---|---|---|---| | `mergeOfflineOnlineData` | boolean | true | Merge offline/online chunk data | +**Fair Play** (`ModConfig.fairPlay`) — client-visible gameplay toggles, aimed at PvP servers that want to dial back or fully disable BLPC's chunk-transit side effects. + +| Option | Type | Default | Description | +|---|---|---|---| +| `enableAreaEffects` | boolean | true | Apply potion effects for area control (weakness/mining fatigue to enemies, resistance/strength to defenders) | +| `enableTransitNotify` | boolean | true | Send toast notifications on claimed-chunk entry/exit | +| `showProtectionStatusHud` | boolean | true | Show `ProtectionStatusHud`'s on-screen indicator while standing in a claimed chunk | + ### Internal defaults (`ModConfig.Defaults` inner class — not in cfg) | Constant | Value | Description | @@ -446,9 +493,7 @@ Uses nested subcategories via `@Config.LangKey` (`config.blpc.`). Acce | `protectMobGriefing` | true | Prevent mob griefing in claims | | `protectFireSpread` | true | Prevent fire spread in claims | | `protectFluidFlow` | true | Prevent fluid flow into claims | -| `enableTransitNotify` | true | Toast notifications for chunk entry/exit | | `transitToastDuration` | 3000 | Toast display duration (ms) | -| `enableAreaEffects` | true | Potion effects for enemies/defenders | | `enemyWeaknessAmplifier` | 0 | Weakness amplifier (0 = level I) | | `enemyMiningFatigue` | true | Mining fatigue for enemies | | `defenderResistanceAmplifier` | 0 | Resistance amplifier (0 = level I) | @@ -483,9 +528,15 @@ Applied every 20 ticks while player is in a claimed chunk: `activeInvasions` map tracks which parties have enemy invaders. Cleaned up on player logout and enemy departure. +### Protection Status HUD + +`client/gui/ProtectionStatusHud` — `RenderGameOverlayEvent.Post` listener, gated by `ModConfig.fairPlay.showProtectionStatusHud`. Purely client-side: resolves relation from `ClientClaimCache`/`ClientPartyCache` data already synced to the client (its own `resolveRelation` mirrors `ChunkTransitHandler`'s server-side version but starts from a `ClaimedChunkData` instead of a `Party`, and short-circuits to `MEMBER` when the local player is the claim's direct owner). On entering a new claimed chunk, shows `blpc.hud.protected_area` centered just above the food/stamina bar (`BOTTOM_MARGIN = 50`, matching vanilla's `height - 39` bar position) for 5 seconds (`DISPLAY_TICKS = 100`), colored via `GuiColors` by relation (`GREEN` member, `GOLD` ally, `RED` enemy, `GRAY` none). Re-arms only on a chunk-coordinate change, not every frame. + + + ## Localization -Lang files in `src/main/resources/assets/blpc/lang/`: `en_us.lang` and `ja_jp.lang`. Both cover keybindings, commands, map UI, party UI, roles, trust actions/levels, protection settings, allies/enemies, tooltips, search, transit notifications (`blpc.transit.*`), party event/claim failure notifications (`blpc.toast.*`), and addon panels (`blpc.addons.*`). +Lang files in `src/main/resources/assets/blpc/lang/`: `en_us.lang` and `ja_jp.lang`. Both cover keybindings, commands, map UI, party UI, roles, trust actions/levels, protection settings, allies/enemies, tooltips, search, transit notifications (`blpc.transit.*`), party event/claim failure notifications (`blpc.toast.*`), addon panels (`blpc.addons.*` — including `blpc.addons.journeymap.waypoints_on`/`waypoints_off`/`waypoints_tooltip` for the team waypoint-sharing toggle), the Fair Play config category (`config.blpc.fair_play`), and the Protection Status HUD (`blpc.hud.protected_area`). ## Adding a New Integration Module diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 4f24565..cce954d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,50 +1,50 @@ name: Claude Code on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] + pull_request: + types: [opened] + +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read jobs: claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - id-token: write - actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - fetch-depth: 1 + fetch-depth: 0 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: + github_token: ${{ secrets.GITHUB_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: "/review" - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' + # Optional: Configure Claude's behavior with CLI arguments + # claude_args: | + # --model claude-opus-4-1-20250805 + # --max-turns 10 + # --allowedTools "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + # --system-prompt "Follow our coding standards. Ensure all new code has tests. Use TypeScript for new files." + # Optional: Advanced settings configuration + # settings: | + # { + # "env": { + # "NODE_ENV": "test" + # } + # } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7818f88 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,91 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "name": "1. Setup Workspace", + "request": "launch", + "command": "./gradlew setupDecompWorkspace; exit", + "windows": { "command": "gradlew.bat setupDecompWorkspace" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "2. Run Client", + "request": "launch", + "command": "./gradlew runClient; exit", + "windows": { "command": "gradlew.bat runClient" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "3. Run Server", + "request": "launch", + "command": "./gradlew runServer; exit", + "windows": { "command": "gradlew.bat runServer" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "4. Run Obfuscated Client", + "request": "launch", + "command": "./gradlew runObfClient; exit", + "windows": { "command": "gradlew.bat runObfClient" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "5. Run Obfuscated Server", + "request": "launch", + "command": "./gradlew runObfServer; exit", + "windows": { "command": "gradlew.bat runObfServer" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "6. Apply Spotless", + "request": "launch", + "command": "./gradlew spotlessApply; exit", + "windows": { "command": "gradlew.bat spotlessApply" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "7. Build Jars", + "request": "launch", + "command": "./gradlew build; exit", + "windows": { "command": "gradlew.bat build" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "Update Buildscript", + "request": "launch", + "command": "./gradlew updateBuildScript; exit", + "windows": { "command": "gradlew.bat updateBuildScript" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "name": "FAQ", + "request": "launch", + "command": "./gradlew faq; exit", + "windows": { "command": "gradlew.bat faq" }, + "cwd": "${workspaceFolder}" + }, + { + "type": "java", + "name": "Attach to Client", + "request": "attach", + "hostName": "localhost", + "port": 5005 + }, + { + "type": "java", + "name": "Attach to Server", + "request": "attach", + "hostName": "localhost", + "port": 5006 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..27013c3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "gradle.nestedProjects": false, + "java.gradle.buildServer.enabled": "on", + "java.compile.nullAnalysis.mode": "automatic", + "files.watcherExclude": { + "**/build/**": true, + "**/.gradle/**": true + }, + "search.exclude": { + "**/build": true, + "**/.gradle": true + }, + "files.exclude": { + "**/build": true, + "**/.gradle": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ac20176..8fcf533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * * * +## [0.14.0] + +### Added + +- **Claim/party data persists across reconnects** + - The chunk map and party menu now show your last-known claims and party info immediately after reconnecting to a server, instead of a blank screen while the server's fresh sync is in flight. + - Cached separately per server/world, so switching between servers never mixes up their claim data. +- **Force-loaded areas stand out on JourneyMap** + - Claim regions where every chunk is force-loaded now render with a bolder, fully opaque outline on JourneyMap, and the region label is now properly localized. +- **Fair play settings** + - New config options let server admins tune area-control potion effects and transit toast notifications independently, for servers that want PvP without a home-field advantage. + - Optional on-screen indicator shows whether you're currently standing in a claimed chunk and who owns it, so PvP fights always make protection status clear. +- **Team waypoint sharing on JourneyMap** + - With JourneyMap installed, a party's waypoints can now be shared with every online member — only the party owner can add, move, or remove them, and members always see the up-to-date result on their own map. + - Toggleable per-player in the Addons menu, under JourneyMap. + +### Changed + +- **Claiming a chunk now requires a party.** Chunk protection is a party-sharing feature, so you must create or join a party before claiming. Singleplayer is unaffected by default (a party is still auto-created on first login). + +### Fixed + +- **BQu-linked parties could drift out of sync with BLPC.** A player who joined an already-linked BetterQuesting party through BQu's own party screen (rather than BLPC's) was previously invisible to BLPC's protection, claim-limit, and party-management logic — they could be wrongly denied access to their own party's claims, get a separate personal claim limit instead of sharing the party's pool, and be unable to use party settings, disband, or unlink through BLPC's UI. Party membership is now resolved consistently between BQu and BLPC in all of these paths. + +[0.14.0]: https://github.com/gtexpert/BetterLinkPartyClaim/releases/tag/v0.14.0 + +* * * + ## [0.13.0] ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8d45fa9..8a6d7ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ RetroFuturaGradle (RFG) + GTNH Buildscripts. **Do not edit `build.gradle`** (aut ## Key Rules -- **Java 17 syntax mandatory** (Jabel → JVM 8): switch expressions (`->`), pattern matching `instanceof`, `var` for obvious types. Details in `.claude/skills/blpc-overview/SKILL.md`. -- **Local builds need JDK 17**: spotless' googlejavaformat can't parse switch expressions on an older daemon JVM. If the Gradle daemon is Java 11/8, run with `-Dorg.gradle.java.home=` (e.g. `/usr/lib/jvm/zulu-17`). Compilation uses the Java 17 toolchain regardless. +- **Java 25 syntax mandatory** (Jabel → JVM 8): switch expressions (`->`), pattern matching `instanceof`, `var` for obvious types. Details in `.claude/skills/blpc-overview/SKILL.md`. +- **Local builds need JDK 25**: spotless' googlejavaformat can't parse switch expressions on an older daemon JVM. If the Gradle daemon is an older Java, run with `-Dorg.gradle.java.home=` (e.g. `/usr/lib/jvm/zulu-25`). Compilation uses the Java 25 toolchain regardless. - **Imports**: Always use `import` statements, not FQCN. Spotless enforces ordering. - **GUI entry points**: open screens through `client/gui/Screens` (the single catalog — `openMap()`, `partyMain(...)`), never `ClientGUI.open(new …)` ad-hoc. Reuse shared drawables from `client/gui/BLPCGuiTextures` (incl. `ICON_*` from ModularUI's `GuiTextures` atlas) instead of inlining drawables. - **GUI colors**: No ModularUI theme system — BLPC ships a single **light** look with colors defined directly in Java. `client/gui/BLPCColors` holds the **semantic** party/map colors (`text()`, `owner()`, `admin()`, `warning()`, `subtext()`, `inactive()`, `divider()`, `mapBackground()`, `mapBorder()`, `textShadow()`) as fixed constants. `client/gui/GuiColors` holds **fixed vanilla-context** colors (`WHITE`/`GOLD`/`GREEN`/`RED`/`GRAY` for toasts, map counters, tooltips, map grid). Use these holders — never inline `0x…` literals (the only exceptions are dynamic per-party `getColor()` ARGB composition). Buttons use ModularUI's default theme; black party text reads against it. Visual changes need `runClient` to verify. diff --git a/dependencies.gradle b/dependencies.gradle index f5e36c2..7dd7121 100755 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -53,6 +53,7 @@ dependencies { // Debug Journey Map api(libs.journeymap.api) + compileOnly rfg.deobf(libs.journeymap.mod.get().toString()) if (runtimeEnabled(project.debug_jmap)) { runtimeOnly rfg.deobf(libs.journeymap.mod.get().toString()) } diff --git a/src/main/java/com/github/gtexpert/blpc/BLPCMod.java b/src/main/java/com/github/gtexpert/blpc/BLPCMod.java index 4b2d387..faa20e7 100644 --- a/src/main/java/com/github/gtexpert/blpc/BLPCMod.java +++ b/src/main/java/com/github/gtexpert/blpc/BLPCMod.java @@ -14,6 +14,7 @@ import net.minecraftforge.fml.common.event.FMLServerStoppingEvent; import com.github.gtexpert.blpc.api.util.Mods; +import com.github.gtexpert.blpc.client.gui.ProtectionStatusHud; import com.github.gtexpert.blpc.client.input.KeyInputHandler; import com.github.gtexpert.blpc.modules.ModuleManager; import com.github.gtexpert.blpc.modules.Modules; @@ -26,8 +27,8 @@ * Functionality lives in modules ({@code CoreModule}, {@code BQuModule}, ...) — * this class only wires Forge into the module pipeline. *

- * Client-only handlers ({@link KeyInputHandler}) are registered during - * {@link #init} on the client side. ModularUI is a hard + * Client-only handlers ({@link KeyInputHandler}, {@link ProtectionStatusHud}) are registered + * during {@link #init} on the client side. ModularUI is a hard * dependency; BetterQuesting and JourneyMap are soft dependencies whose * integrations live behind their own modules. */ @@ -35,7 +36,8 @@ version = Tags.VERSION, name = Tags.MODNAME, acceptedMinecraftVersions = "[1.12.2]", - dependencies = "required-after:" + Mods.Names.MODULAR_UI + ";" + "after:" + Mods.Names.BETTER_QUESTING + ";" + + dependencies = "required-after:" + Mods.Names.MODULAR_UI + ";" + + "after:" + Mods.Names.BETTER_QUESTING + ";" + "after:" + Mods.Names.JOURNEY_MAP + ";") public class BLPCMod { @@ -64,6 +66,7 @@ public void init(FMLInitializationEvent event) { if (event.getSide().isClient()) { KeyInputHandler.init(); MinecraftForge.EVENT_BUS.register(new KeyInputHandler()); + MinecraftForge.EVENT_BUS.register(new ProtectionStatusHud()); } } diff --git a/src/main/java/com/github/gtexpert/blpc/api/party/IPartyProvider.java b/src/main/java/com/github/gtexpert/blpc/api/party/IPartyProvider.java index c5ec219..4827700 100644 --- a/src/main/java/com/github/gtexpert/blpc/api/party/IPartyProvider.java +++ b/src/main/java/com/github/gtexpert/blpc/api/party/IPartyProvider.java @@ -40,6 +40,35 @@ public interface IPartyProvider { @Nullable String getRole(UUID playerUUID); + /** + * Returns a stable identifier for the player's party, or {@code null} if they have no party. + * Unlike the display-facing {@link Party} objects synced to clients (whose id can vary by + * which member's data happened to seed it — see {@code BQuPartyProvider#serializeForClient}), + * this id is guaranteed identical for every member of the same real party, making it safe as + * a server-side storage key (e.g. {@code WaypointManagerData}) even for members who have + * never had their own BLPC-side party record created. + */ + @Nullable + default UUID getPartyId(UUID playerUUID) { + return null; + } + + /** + * Returns the player's fully-populated {@link Party} (members, trust levels, allies/enemies, + * claim-limit settings) for authoritative server-side checks — chunk protection trust + * resolution, additive claim/force-load limits, and chunk-transit relation notifications. + *

+ * Unlike reading a self-managed {@code PartyManagerData} record directly, this is guaranteed + * to reflect live membership even for a member who joined entirely through a delegate's own + * UI (e.g. BQu's native party screen) and so has no BLPC-side {@link Party} record of their + * own — see {@code BQuPartyProvider#getEffectiveParty}. Returns {@code null} if the player has + * no party. + */ + @Nullable + default Party getEffectiveParty(UUID playerUUID) { + return null; + } + /** Returns the party with the given name, or null if none exists. */ @Nullable default Party findByName(String name) { @@ -84,6 +113,20 @@ default boolean hasNativeParty(UUID playerUUID) { return getPartyName(playerUUID) != null; } + /** + * Returns true if this player's current native party has been linked to BLPC (i.e. any of + * its members opted in via {@code ACTION_TOGGLE_BQU_LINK}), so mutations for this player + * should route through this provider instead of the self-managed fallback. + *

+ * Unlike a per-player flag snapshotted at link time, this reflects the party's current + * membership: a player who joins an already-linked native party afterward (e.g. through BQu's + * own party screen, entirely outside BLPC) is recognized immediately, without requiring a + * separate propagation step every time membership changes elsewhere. + */ + default boolean isLinkedParty(UUID playerUUID) { + return false; + } + /** * Ensures a native party exists for the owner with the same members as the * given BLPC party. Creates the native party if absent, adds missing diff --git a/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCacheKey.java b/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCacheKey.java new file mode 100644 index 0000000..6874158 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCacheKey.java @@ -0,0 +1,36 @@ +package com.github.gtexpert.blpc.client.cache; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** + * Derives a filesystem-safe identifier for the server the client is currently connected to, + * so per-server claim/party caches never mix data between different worlds or servers. + */ +@SideOnly(Side.CLIENT) +public final class ClientCacheKey { + + private ClientCacheKey() {} + + /** + * Returns a sanitized identifier for the current connection, or {@code null} if not + * connected to any world (e.g. on the main menu). + */ + public static String current() { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.isIntegratedServerRunning()) { + var server = mc.getIntegratedServer(); + if (server == null) return null; + return "sp_" + sanitize(server.getFolderName()); + } + ServerData data = mc.getCurrentServerData(); + if (data == null || data.serverIP == null) return null; + return "mp_" + sanitize(data.serverIP); + } + + private static String sanitize(String raw) { + return raw.replaceAll("[^a-zA-Z0-9._-]", "_"); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCachePersistence.java b/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCachePersistence.java new file mode 100644 index 0000000..19822fd --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/client/cache/ClientCachePersistence.java @@ -0,0 +1,232 @@ +package com.github.gtexpert.blpc.client.cache; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import net.minecraft.client.Minecraft; +import net.minecraft.nbt.CompressedStreamTools; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraftforge.common.util.Constants; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.common.ModLog; +import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; +import com.github.gtexpert.blpc.common.chunk.ClientClaimCache; +import com.github.gtexpert.blpc.common.party.ClientPartyCache; + +/** + * Persists {@link ClientClaimCache} and {@link ClientPartyCache} to disk, keyed by the + * currently connected server ({@link ClientCacheKey}), so the map/party UI can show the + * last-known state immediately after reconnecting instead of an empty screen while the + * server's fresh sync is in flight. + *

+ * Writes are debounced: every cache change schedules a single delayed write, and a change + * that arrives before the delay elapses cancels and reschedules it. This keeps disk I/O off + * the hot path (a bulk {@code SyncAllClaims}/{@code PartySync} triggers many individual + * cache updates) while still surviving anything short of a hard process kill. + *

+ * {@link ClientClaimCache} and {@link ClientPartyCache} are plain, non-thread-safe collections + * that the rest of the codebase only ever touches from the client main thread (GUI click + * handlers, {@code MainThreadMessageHandler}-based sync handlers). Every method here that reads + * or mutates them is therefore required to run on the main thread too: public entry points hop + * onto it via {@link Minecraft#addScheduledTask} before touching cache state, and the debounce + * timer (which fires on a background thread) only decides *when* to hop back, never touches the + * caches itself. + */ +@SideOnly(Side.CLIENT) +public final class ClientCachePersistence { + + private static final long DEBOUNCE_MS = 2000; + private static final String CLAIMS_FILE = "claims.dat"; + private static final String PARTIES_FILE = "parties.dat"; + + private static final ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "BLPC-ClientCacheSave"); + t.setDaemon(true); + return t; + }); + private static final ScheduledExecutorService IO_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "BLPC-ClientCacheIO"); + t.setDaemon(true); + return t; + }); + + private static ScheduledFuture pendingSave; + private static final Runnable claimListener = ClientCachePersistence::scheduleSave; + private static final Runnable partyListener = ClientCachePersistence::scheduleSave; + + // Captured at connect and reused by saves: a save deferred past disconnect can't rely on + // ClientCacheKey.current(), which may already read null (e.g. singleplayer world unload). + private static volatile String connectedKey; + + private ClientCachePersistence() {} + + /** Registers the debounced auto-save listeners. Must be called on the main thread. */ + public static void register() { + ClientClaimCache.addChangeListener(claimListener); + ClientPartyCache.addSyncListener(partyListener); + } + + /** Unregisters the debounced auto-save listeners. Must be called on the main thread. */ + public static void unregister() { + ClientClaimCache.removeChangeListener(claimListener); + ClientPartyCache.removeSyncListener(partyListener); + connectedKey = null; + } + + /** + * Loads any previously saved cache for the current connection. Must be called on the main + * thread, before the server's fresh sync arrives (so the fresh sync naturally overwrites + * this best-effort snapshot). Also captures the current cache key for the lifetime of the + * connection, so subsequent saves don't depend on live connection state. + */ + public static void loadForCurrentServer() { + connectedKey = ClientCacheKey.current(); + if (connectedKey == null) return; + + File dir = serverDir(connectedKey); + loadClaims(new File(dir, CLAIMS_FILE)); + loadParties(new File(dir, PARTIES_FILE)); + } + + /** Cancels any pending debounced save. Safe to call from any thread. */ + public static synchronized void cancelPending() { + if (pendingSave != null) { + pendingSave.cancel(false); + pendingSave = null; + } + } + + /** + * Immediately snapshots and persists the current cache contents, bypassing the debounce + * delay. Must be called on the main thread (typically on disconnect, before the caches are + * cleared). + */ + public static void saveNow() { + cancelPending(); + snapshotAndWrite(); + } + + /** Debounce-timer callback (background thread) — only re-arms the main-thread hop. */ + private static synchronized void scheduleSave() { + if (pendingSave != null) { + pendingSave.cancel(false); + } + pendingSave = SCHEDULER.schedule( + () -> Minecraft.getMinecraft().addScheduledTask(ClientCachePersistence::snapshotAndWrite), + DEBOUNCE_MS, TimeUnit.MILLISECONDS); + } + + /** Builds the NBT snapshot on the calling (main) thread, then hands file I/O to a background thread. */ + private static void snapshotAndWrite() { + String key = connectedKey; + if (key == null) return; + + NBTTagCompound claimsNbt = buildClaimsNBT(); + NBTTagCompound partiesNbt = buildPartiesNBT(); + + IO_EXECUTOR.execute(() -> { + File dir = serverDir(key); + dir.mkdirs(); + writeCompressedAtomic(new File(dir, CLAIMS_FILE), claimsNbt); + writeCompressedAtomic(new File(dir, PARTIES_FILE), partiesNbt); + }); + } + + private static File serverDir(String key) { + return new File(Minecraft.getMinecraft().gameDir, "blpc/cache/" + key); + } + + // --- Claims --- + + private static void loadClaims(File file) { + if (!file.exists()) return; + try (FileInputStream fis = new FileInputStream(file)) { + NBTTagCompound nbt = CompressedStreamTools.readCompressed(fis); + NBTTagList list = nbt.getTagList("claims", Constants.NBT.TAG_COMPOUND); + for (int i = 0; i < list.tagCount(); i++) { + ClaimedChunkData d = ClaimedChunkData.fromNBT(list.getCompoundTagAt(i)); + if (d == null) continue; + ClientClaimCache.update(d.x, d.z, d.ownerUUID, d.ownerName, d.partyName, d.isForceLoaded); + } + } catch (IOException e) { + ModLog.IO.warn("Failed to load cached claims from {}", file.getName(), e); + } + } + + private static NBTTagCompound buildClaimsNBT() { + NBTTagList list = new NBTTagList(); + for (ClaimedChunkData claim : ClientClaimCache.getAll()) { + list.appendTag(claim.toNBT()); + } + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setTag("claims", list); + return nbt; + } + + // --- Parties --- + + private static void loadParties(File file) { + if (!file.exists()) return; + try (FileInputStream fis = new FileInputStream(file)) { + NBTTagCompound nbt = CompressedStreamTools.readCompressed(fis); + ClientPartyCache.loadFromNBT(nbt); + } catch (IOException e) { + ModLog.IO.warn("Failed to load cached parties from {}", file.getName(), e); + } + } + + private static NBTTagCompound buildPartiesNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + + NBTTagList partyList = new NBTTagList(); + for (Party party : ClientPartyCache.getAllParties()) { + partyList.appendTag(party.toNBT()); + } + nbt.setTag("parties", partyList); + + NBTTagList linkedList = new NBTTagList(); + for (UUID uuid : ClientPartyCache.getBQuLinkedPlayers()) { + NBTTagCompound entry = new NBTTagCompound(); + entry.setUniqueId("uuid", uuid); + linkedList.appendTag(entry); + } + nbt.setTag("bquLinked", linkedList); + + return nbt; + } + + // --- I/O (background thread only) --- + + // Unlike BLPCSaveHandler's server-side sibling of the same name, this intentionally skips + // fos.getFD().sync() before rename: this cache is a best-effort UX convenience, not + // authoritative data — the server remains the source of truth and re-syncs on reconnect, so + // losing the last few buffered KB on a hard crash is an acceptable trade for cheaper writes. + private static void writeCompressedAtomic(File file, NBTTagCompound nbt) { + File tmpFile = new File(file.getParentFile(), file.getName() + ".tmp"); + try (FileOutputStream fos = new FileOutputStream(tmpFile)) { + CompressedStreamTools.writeCompressed(nbt, fos); + } catch (IOException e) { + ModLog.IO.warn("Failed to write client cache file {}", file.getName(), e); + tmpFile.delete(); + return; + } + try { + Files.move(tmpFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + ModLog.IO.warn("Failed to finalize client cache file {}", file.getName(), e); + } + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/client/gui/BLPCToast.java b/src/main/java/com/github/gtexpert/blpc/client/gui/BLPCToast.java index d0cff61..12ecbce 100644 --- a/src/main/java/com/github/gtexpert/blpc/client/gui/BLPCToast.java +++ b/src/main/java/com/github/gtexpert/blpc/client/gui/BLPCToast.java @@ -177,9 +177,9 @@ public Builder fromPartyEvent(String eventType, String playerName, String partyN /** * Configures the toast for a claim failure notification. * - * @param reason failure reason ("CLAIM_LIMIT" or "FORCELOAD_LIMIT") - * @param current current count - * @param max maximum allowed count + * @param reason failure reason ("CLAIM_LIMIT", "FORCELOAD_LIMIT", or "NO_PARTY") + * @param current current count (unused for "NO_PARTY") + * @param max maximum allowed count (unused for "NO_PARTY") */ public Builder fromClaimFailed(String reason, int current, int max) { switch (reason) { @@ -193,6 +193,11 @@ public Builder fromClaimFailed(String reason, int current, int max) { this.titleArgs = new Object[] { current, max }; this.color = GuiColors.RED; } + case "NO_PARTY" -> { + this.titleKey = "blpc.toast.no_party"; + this.titleArgs = new Object[] {}; + this.color = GuiColors.RED; + } default -> this.titleKey = ""; } return this; diff --git a/src/main/java/com/github/gtexpert/blpc/client/gui/ProtectionStatusHud.java b/src/main/java/com/github/gtexpert/blpc/client/gui/ProtectionStatusHud.java new file mode 100644 index 0000000..b1aefce --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/client/gui/ProtectionStatusHud.java @@ -0,0 +1,103 @@ +package com.github.gtexpert.blpc.client.gui; + +import java.util.UUID; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.ScaledResolution; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.client.event.RenderGameOverlayEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.RelationType; +import com.github.gtexpert.blpc.common.ModConfig; +import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; +import com.github.gtexpert.blpc.common.chunk.ClientClaimCache; +import com.github.gtexpert.blpc.common.party.ClientPartyCache; + +/** + * Draws a brief on-screen indicator just above the food/stamina bar whenever the local player + * crosses into a claimed chunk, so PvP fights always make it clear whether either side currently + * has claim protection. Gated by {@link ModConfig.FairPlay#showProtectionStatusHud} — this is a + * client-side display preference, evaluated purely from data already synced to + * {@link ClientClaimCache}/{@link ClientPartyCache}. + */ +@SideOnly(Side.CLIENT) +public final class ProtectionStatusHud { + + /** Vanilla's food/stamina bar starts at {@code height - 39} (GuiIngameForge.left_height/right_height). */ + private static final int BOTTOM_MARGIN = 50; + private static final int DISPLAY_TICKS = 100; // 5 seconds at 20 ticks/sec + + private long lastChunkKey = Long.MIN_VALUE; + private int hideAtTick = -1; + private String cachedText; + private int cachedColor; + + @SubscribeEvent + public void onRenderOverlay(RenderGameOverlayEvent.Post event) { + if (event.getType() != RenderGameOverlayEvent.ElementType.ALL) return; + if (!ModConfig.fairPlay.showProtectionStatusHud) return; + + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null || mc.world == null) return; + + long chunkKey = pack(mc.player.chunkCoordX, mc.player.chunkCoordZ); + if (chunkKey != lastChunkKey) { + lastChunkKey = chunkKey; + onChunkChanged(mc); + } + + if (hideAtTick < 0 || mc.player.ticksExisted > hideAtTick) return; + + ScaledResolution resolution = event.getResolution(); + FontRenderer font = mc.fontRenderer; + int x = (resolution.getScaledWidth() - font.getStringWidth(cachedText)) / 2; + font.drawStringWithShadow(cachedText, x, resolution.getScaledHeight() - BOTTOM_MARGIN, cachedColor); + } + + /** Re-arms the 5-second display window whenever the player enters a newly-claimed chunk. */ + private void onChunkChanged(Minecraft mc) { + ClaimedChunkData claim = ClientClaimCache.get(mc.player.chunkCoordX, mc.player.chunkCoordZ); + if (claim == null) { + hideAtTick = -1; + return; + } + + RelationType relation = resolveRelation(claim, mc.player.getUniqueID()); + cachedText = I18n.format("blpc.hud.protected_area", + claim.partyName.isEmpty() ? claim.ownerName : claim.partyName); + cachedColor = colorFor(relation); + hideAtTick = mc.player.ticksExisted + DISPLAY_TICKS; + } + + private static long pack(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + private static RelationType resolveRelation(ClaimedChunkData claim, UUID localPlayerId) { + if (claim.ownerUUID.equals(localPlayerId)) return RelationType.MEMBER; + + Party ownerParty = ClientPartyCache.getPartyByPlayer(claim.ownerUUID); + if (ownerParty == null) return RelationType.NONE; + if (ownerParty.isMember(localPlayerId)) return RelationType.MEMBER; + + Party localParty = ClientPartyCache.getPartyByPlayer(localPlayerId); + if (localParty == null) return RelationType.NONE; + if (ownerParty.isAlly(localParty.getPartyId())) return RelationType.ALLY; + if (ownerParty.isEnemy(localParty.getPartyId())) return RelationType.ENEMY; + return RelationType.NONE; + } + + private static int colorFor(RelationType relation) { + return switch (relation) { + case MEMBER -> GuiColors.GREEN; + case ALLY -> GuiColors.GOLD; + case ENEMY -> GuiColors.RED; + case NONE -> GuiColors.GRAY; + }; + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/client/network/ClientPacketHandlers.java b/src/main/java/com/github/gtexpert/blpc/client/network/ClientPacketHandlers.java index 02c49a8..bd7c034 100644 --- a/src/main/java/com/github/gtexpert/blpc/client/network/ClientPacketHandlers.java +++ b/src/main/java/com/github/gtexpert/blpc/client/network/ClientPacketHandlers.java @@ -6,8 +6,10 @@ import com.github.gtexpert.blpc.common.network.message.ClientNotify; import com.github.gtexpert.blpc.common.network.message.PartySync; import com.github.gtexpert.blpc.common.network.message.SyncAllClaims; +import com.github.gtexpert.blpc.common.network.message.SyncAllWaypoints; import com.github.gtexpert.blpc.common.network.message.SyncClaims; import com.github.gtexpert.blpc.common.network.message.SyncConfig; +import com.github.gtexpert.blpc.common.network.message.WaypointSync; /** * Side-aware installer for all S→C client handlers. @@ -43,5 +45,9 @@ public static void installAll(SimpleNetworkWrapper channel, int firstId) { id++, Side.CLIENT); channel.registerMessage(ClientNotifyClientHandler.class, ClientNotify.class, id++, Side.CLIENT); + channel.registerMessage(WaypointSyncClientHandler.class, WaypointSync.class, + id++, Side.CLIENT); + channel.registerMessage(SyncAllWaypointsClientHandler.class, SyncAllWaypoints.class, + id++, Side.CLIENT); } } diff --git a/src/main/java/com/github/gtexpert/blpc/client/network/SyncAllWaypointsClientHandler.java b/src/main/java/com/github/gtexpert/blpc/client/network/SyncAllWaypointsClientHandler.java new file mode 100644 index 0000000..92a9f91 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/client/network/SyncAllWaypointsClientHandler.java @@ -0,0 +1,31 @@ +package com.github.gtexpert.blpc.client.network; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraftforge.common.util.Constants; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.common.network.message.SyncAllWaypoints; +import com.github.gtexpert.blpc.common.waypoint.ClientWaypointCache; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; + +/** Client-side handler for the full party-waypoint sync sent on login. */ +@SideOnly(Side.CLIENT) +public final class SyncAllWaypointsClientHandler extends MainThreadMessageHandler { + + @Override + protected void handleOnMainThread(SyncAllWaypoints msg) { + NBTTagCompound data = msg.getData(); + var list = data.getTagList("waypoints", Constants.NBT.TAG_COMPOUND); + List waypoints = new ArrayList<>(); + for (int i = 0; i < list.tagCount(); i++) { + PartyWaypointData d = PartyWaypointData.fromNBT(list.getCompoundTagAt(i)); + if (d == null) continue; + waypoints.add(d); + } + ClientWaypointCache.loadAll(waypoints); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/client/network/WaypointSyncClientHandler.java b/src/main/java/com/github/gtexpert/blpc/client/network/WaypointSyncClientHandler.java new file mode 100644 index 0000000..db5d409 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/client/network/WaypointSyncClientHandler.java @@ -0,0 +1,24 @@ +package com.github.gtexpert.blpc.client.network; + +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.common.network.message.WaypointAction; +import com.github.gtexpert.blpc.common.network.message.WaypointSync; +import com.github.gtexpert.blpc.common.waypoint.ClientWaypointCache; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; + +/** Client-side handler for a single party-waypoint add/update/remove. */ +@SideOnly(Side.CLIENT) +public final class WaypointSyncClientHandler extends MainThreadMessageHandler { + + @Override + protected void handleOnMainThread(WaypointSync msg) { + if (msg.getAction() == WaypointAction.ACTION_REMOVE) { + ClientWaypointCache.remove(msg.getWaypointId()); + return; + } + ClientWaypointCache.update(new PartyWaypointData(msg.getWaypointId(), msg.getOwnerUUID(), + msg.getName(), msg.getDimension(), msg.getX(), msg.getY(), msg.getZ(), msg.getColor())); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/BLPCSaveHandler.java b/src/main/java/com/github/gtexpert/blpc/common/BLPCSaveHandler.java index 4ef0310..e810aae 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/BLPCSaveHandler.java +++ b/src/main/java/com/github/gtexpert/blpc/common/BLPCSaveHandler.java @@ -25,6 +25,8 @@ import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; import com.github.gtexpert.blpc.common.chunk.TicketManager; import com.github.gtexpert.blpc.common.party.PartyManagerData; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; +import com.github.gtexpert.blpc.common.waypoint.WaypointManagerData; /** * File-based persistence handler for BLPC data. @@ -54,6 +56,7 @@ public class BLPCSaveHandler { private File dataDir; private File partiesDir; private File claimsDir; + private File waypointsDir; private volatile boolean dirty = false; private BLPCSaveHandler() {} @@ -73,9 +76,11 @@ public void initWorldDir(MinecraftServer server) { dataDir = new File(worldDir, "betterlink/pc"); partiesDir = new File(dataDir, "parties"); claimsDir = new File(dataDir, "claims"); + waypointsDir = new File(dataDir, "waypoints"); dataDir.mkdirs(); partiesDir.mkdirs(); claimsDir.mkdirs(); + waypointsDir.mkdirs(); } // --- Config --- @@ -211,6 +216,63 @@ public void saveClaims(ChunkManagerData chunkData, PartyManagerData partyData) { backupAndSwap(claimsDir, "claims", tmpFiles, "claim"); } + // --- Waypoints (per-party files) --- + + public void loadWaypoints(WaypointManagerData data) { + File[] files = waypointsDir.listFiles((dir, name) -> name.endsWith(".dat")); + if (files == null) return; + for (File file : files) { + String baseName = file.getName().substring(0, file.getName().length() - ".dat".length()); + UUID partyId; + try { + partyId = UUID.fromString(baseName); + } catch (IllegalArgumentException e) { + ModLog.IO.error("Invalid party id in waypoint file name: {}", file.getName()); + continue; + } + try (FileInputStream fis = new FileInputStream(file)) { + NBTTagCompound nbt = CompressedStreamTools.readCompressed(fis); + NBTTagList list = nbt.getTagList("waypoints", Constants.NBT.TAG_COMPOUND); + Map waypoints = new HashMap<>(); + for (int i = 0; i < list.tagCount(); i++) { + PartyWaypointData waypoint = PartyWaypointData.fromNBT(list.getCompoundTagAt(i)); + if (waypoint == null) continue; + waypoints.put(waypoint.waypointId, waypoint); + } + data.loadParty(partyId, waypoints); + } catch (IOException e) { + ModLog.IO.error("Failed to load waypoint file: {}", file.getName(), e); + } + } + } + + public void saveWaypoints(WaypointManagerData data) { + Map toWrite = new HashMap<>(); + for (var entry : data.getAllForSave().entrySet()) { + if (entry.getValue().isEmpty()) continue; + NBTTagList list = new NBTTagList(); + for (PartyWaypointData waypoint : entry.getValue().values()) { + list.appendTag(waypoint.toNBT()); + } + toWrite.put(entry.getKey().toString(), list); + } + + List tmpFiles = new ArrayList<>(); + for (var entry : toWrite.entrySet()) { + var tmpFile = new File(waypointsDir, entry.getKey() + ".dat.tmp"); + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setTag("waypoints", entry.getValue()); + if (!writeCompressedTemp(tmpFile, nbt)) { + for (File tmp : tmpFiles) tmp.delete(); + ModLog.IO.error("Waypoints save aborted; old files preserved"); + return; + } + tmpFiles.add(tmpFile); + } + + backupAndSwap(waypointsDir, "waypoints", tmpFiles, "waypoint"); + } + private boolean writeCompressedTemp(File tmpFile, NBTTagCompound nbt) { try (var fos = new FileOutputStream(tmpFile)) { CompressedStreamTools.writeCompressed(nbt, fos); @@ -277,12 +339,15 @@ public synchronized void loadAll(MinecraftServer server) { initWorldDir(server); ChunkManagerData.reset(); PartyManagerData.reset(); + WaypointManagerData.reset(); TicketManager.reset(); PartyManagerData partyData = PartyManagerData.getInstance(); ChunkManagerData chunkData = ChunkManagerData.getInstance(); + WaypointManagerData waypointData = WaypointManagerData.getInstance(); loadConfig(partyData); loadParties(partyData); loadClaims(chunkData); + loadWaypoints(waypointData); } public synchronized void saveAll() { @@ -297,6 +362,7 @@ private void saveAllInternal() { ChunkManagerData chunkData = ChunkManagerData.getInstance(); saveConfig(partyData); saveParties(partyData); + saveWaypoints(WaypointManagerData.getInstance()); saveClaims(chunkData, partyData); } } diff --git a/src/main/java/com/github/gtexpert/blpc/common/ModConfig.java b/src/main/java/com/github/gtexpert/blpc/common/ModConfig.java index cba5a81..9fa4820 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/ModConfig.java +++ b/src/main/java/com/github/gtexpert/blpc/common/ModConfig.java @@ -18,9 +18,7 @@ public static final class Defaults { public static final boolean protectMobGriefing = true; public static final boolean protectFireSpread = true; public static final boolean protectFluidFlow = true; - public static final boolean enableTransitNotify = true; public static final int transitToastDuration = 3000; - public static final boolean enableAreaEffects = true; public static final int enemyWeaknessAmplifier = 0; public static final boolean enemyMiningFatigue = true; public static final int defenderResistanceAmplifier = 0; @@ -43,6 +41,9 @@ private Defaults() {} @Config.LangKey("config.blpc.protection") public static final Protection protection = new Protection(); + @Config.LangKey("config.blpc.fair_play") + public static final FairPlay fairPlay = new FairPlay(); + public static class Claims { @Config.Name("Max Claims Per Player") @@ -79,6 +80,24 @@ public static class Protection { public String[] itemUseBlacklist = {}; } + public static class FairPlay { + + @Config.Name("Enable Area Effects") + @Config.Comment("Apply potion effects for area control: weakness/mining fatigue to enemies inside a claim, " + + "resistance/strength to defenders while enemies are present. Disable for PvP servers where " + + "this home-field advantage would be considered unfair.") + public boolean enableAreaEffects = true; + + @Config.Name("Enable Transit Notifications") + @Config.Comment("Send toast notifications when a member/ally/enemy enters or leaves a claimed chunk.") + public boolean enableTransitNotify = true; + + @Config.Name("Show Protection Status HUD") + @Config.Comment("Show an on-screen indicator while standing in a claimed chunk, so you always know " + + "whether you're currently protected during PvP.") + public boolean showProtectionStatusHud = true; + } + public static class Party { @Config.Name("Auto Create Party (Singleplayer)") diff --git a/src/main/java/com/github/gtexpert/blpc/common/command/BLPCCommandHelper.java b/src/main/java/com/github/gtexpert/blpc/common/command/BLPCCommandHelper.java index a96f484..211e334 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/command/BLPCCommandHelper.java +++ b/src/main/java/com/github/gtexpert/blpc/common/command/BLPCCommandHelper.java @@ -14,12 +14,10 @@ import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; import com.github.gtexpert.blpc.api.util.PartyQueryUtil; import com.github.gtexpert.blpc.common.party.DefaultPartyProvider; -import com.github.gtexpert.blpc.common.party.PartyManagerData; /** * Internal command-layer helpers. Query methods delegate to {@link PartyQueryUtil}; - * only {@link #activeProviderFor} stays here because it depends on the BQu-link flag - * stored in {@link PartyManagerData}. + * only {@link #activeProviderFor} stays here because it depends on {@link IPartyProvider#isLinkedParty}. */ public final class BLPCCommandHelper { @@ -62,11 +60,13 @@ public static String resolveOwnerName(MinecraftServer server, Party party) { } /** - * Returns the provider that should handle a player-initiated mutation. - * BQu-linked players use the registered BQu provider; others use the self-managed default. + * Returns the provider that should handle a player-initiated mutation. Players in a linked + * BQu party (checked live via {@link IPartyProvider#isLinkedParty}, not a per-player flag — + * see {@code PartyAction.Handler#dispatch}) use the registered BQu provider; others use the + * self-managed default. */ public static IPartyProvider activeProviderFor(EntityPlayerMP player) { - boolean linked = PartyManagerData.getInstance().isBQuLinked(player.getUniqueID()); - return linked ? PartyProviderRegistry.get() : SELF_PROVIDER; + IPartyProvider provider = PartyProviderRegistry.get(); + return provider.isLinkedParty(player.getUniqueID()) ? provider : SELF_PROVIDER; } } diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/ModNetwork.java b/src/main/java/com/github/gtexpert/blpc/common/network/ModNetwork.java index 3a0a630..54d1ddc 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/network/ModNetwork.java +++ b/src/main/java/com/github/gtexpert/blpc/common/network/ModNetwork.java @@ -14,8 +14,11 @@ import com.github.gtexpert.blpc.common.network.message.PartyAction; import com.github.gtexpert.blpc.common.network.message.PartySync; import com.github.gtexpert.blpc.common.network.message.SyncAllClaims; +import com.github.gtexpert.blpc.common.network.message.SyncAllWaypoints; import com.github.gtexpert.blpc.common.network.message.SyncClaims; import com.github.gtexpert.blpc.common.network.message.SyncConfig; +import com.github.gtexpert.blpc.common.network.message.WaypointAction; +import com.github.gtexpert.blpc.common.network.message.WaypointSync; /** * Network channel initialization. Messages use incrementing discriminator IDs. @@ -42,7 +45,7 @@ public class ModNetwork { @SuppressWarnings("unchecked") private static Class[] clientBoundMessages() { return new Class[] { SyncClaims.class, SyncAllClaims.class, SyncConfig.class, - PartySync.class, ClientNotify.class }; + PartySync.class, ClientNotify.class, WaypointSync.class, SyncAllWaypoints.class }; } public static void init() { @@ -51,6 +54,7 @@ public static void init() { // C→S: server handlers live in common.network and have no client-only references. INSTANCE.registerMessage(ClaimChunk.Handler.class, ClaimChunk.class, id++, Side.SERVER); INSTANCE.registerMessage(PartyAction.Handler.class, PartyAction.class, id++, Side.SERVER); + INSTANCE.registerMessage(WaypointAction.Handler.class, WaypointAction.class, id++, Side.SERVER); // S→C: handlers live in client.network and reference @SideOnly(CLIENT) classes // (Minecraft, IToast, etc.). Loading them on a dedicated server triggers the diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/PlayerLoginHandler.java b/src/main/java/com/github/gtexpert/blpc/common/network/PlayerLoginHandler.java index fb8e165..565e6fe 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/network/PlayerLoginHandler.java +++ b/src/main/java/com/github/gtexpert/blpc/common/network/PlayerLoginHandler.java @@ -6,13 +6,17 @@ import java.util.UUID; import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; import net.minecraft.server.MinecraftServer; import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.FMLCommonHandler; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.fml.common.gameevent.PlayerEvent; import com.github.gtexpert.blpc.Tags; +import com.github.gtexpert.blpc.api.event.PartyEvent; import com.github.gtexpert.blpc.api.party.IPartyProvider; import com.github.gtexpert.blpc.api.party.Party; import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; @@ -25,8 +29,11 @@ import com.github.gtexpert.blpc.common.chunk.TicketManager; import com.github.gtexpert.blpc.common.network.message.PartySync; import com.github.gtexpert.blpc.common.network.message.SyncAllClaims; +import com.github.gtexpert.blpc.common.network.message.SyncAllWaypoints; import com.github.gtexpert.blpc.common.network.message.SyncConfig; import com.github.gtexpert.blpc.common.party.PartyManagerData; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; +import com.github.gtexpert.blpc.common.waypoint.WaypointManagerData; /** Sends initial sync packets (claims, config, parties) to newly connected players. */ @Mod.EventBusSubscriber(modid = Tags.MODID) @@ -78,7 +85,7 @@ public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { // Re-force party chunks if this is the first member logging in after offline suppression if (!ModConfig.claims.allowOfflineChunkLoading) { - Party party = PartyManagerData.getInstance().getPartyByPlayer(player.getUniqueID()); + Party party = activeProvider.getEffectiveParty(player.getUniqueID()); if (party != null) { MinecraftServer server = player.getServer(); if (server != null && party.countOnlineMembers(server) == 1) { @@ -103,5 +110,54 @@ public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { ModNetwork.INSTANCE.sendTo( new PartySync(PartyProviderRegistry.get().serializeForClient()), player); + + // getPartyId(), not PartyManagerData#getPartyByPlayer() — see WaypointAction.Handler's + // javadoc: a BQu-linked player who joined entirely through BQu's own UI may have no + // BLPC-side Party record, and getPartyId() is the only id guaranteed stable across members. + sendWaypointSync(player, activeProvider.getPartyId(player.getUniqueID())); + } + + /** Mid-session join: sync the party's shared waypoints without waiting for a relog. */ + @SubscribeEvent + public static void onMemberJoined(PartyEvent.Post.MemberJoined event) { + EntityPlayerMP member = onlinePlayer(event.getMemberUUID()); + if (member == null) return; + sendWaypointSync(member, PartyProviderRegistry.get().getPartyId(event.getMemberUUID())); + } + + /** Left/kicked: clear the party's shared waypoints (null id sends an empty list). */ + @SubscribeEvent + public static void onMemberLeft(PartyEvent.Post.MemberLeft event) { + EntityPlayerMP member = onlinePlayer(event.getMemberUUID()); + if (member == null) return; + sendWaypointSync(member, null); + } + + /** Disbanded: every former member drops the now-orphaned shared waypoints. */ + @SubscribeEvent + public static void onPartyDisbanded(PartyEvent.Post.Disbanded event) { + for (UUID memberId : event.getMemberUUIDs()) { + EntityPlayerMP member = onlinePlayer(memberId); + if (member != null) sendWaypointSync(member, null); + } + } + + private static EntityPlayerMP onlinePlayer(UUID playerId) { + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + if (server == null) return null; + return server.getPlayerList().getPlayerByUUID(playerId); + } + + /** Sends {@code player} the full shared-waypoint list for {@code partyId} (empty if null). */ + private static void sendWaypointSync(EntityPlayerMP player, UUID partyId) { + NBTTagCompound waypointsData = new NBTTagCompound(); + NBTTagList waypointsList = new NBTTagList(); + if (partyId != null) { + for (PartyWaypointData waypoint : WaypointManagerData.getInstance().getWaypoints(partyId)) { + waypointsList.appendTag(waypoint.toNBT()); + } + } + waypointsData.setTag("waypoints", waypointsList); + ModNetwork.INSTANCE.sendTo(new SyncAllWaypoints(waypointsData), player); } } diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/ClaimChunk.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/ClaimChunk.java index a6b2e67..f9ccbcc 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/network/message/ClaimChunk.java +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/ClaimChunk.java @@ -20,7 +20,6 @@ import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; import com.github.gtexpert.blpc.common.chunk.TicketManager; import com.github.gtexpert.blpc.common.network.ModNetwork; -import com.github.gtexpert.blpc.common.party.PartyManagerData; import io.netty.buffer.ByteBuf; @@ -90,6 +89,7 @@ public IMessage onMessage(ClaimChunk message, MessageContext ctx) { private void handleClaim(ClaimChunk msg, EntityPlayerMP player, ChunkManagerData data, ClaimedChunkData existing, UUID playerId) { if (existing != null) return; + if (isPartyMissing(playerId, player)) return; if (isClaimLimitReached(data, playerId, player)) return; if (MinecraftForge.EVENT_BUS.post( new ChunkModifiedEvent.Pre.Claim(msg.x, msg.z, playerId))) { @@ -125,6 +125,7 @@ private void handleUnclaim(ClaimChunk msg, EntityPlayerMP player, private void handleToggleForce(ClaimChunk msg, EntityPlayerMP player, ChunkManagerData data, ClaimedChunkData existing, UUID playerId) { if (existing == null) { + if (isPartyMissing(playerId, player)) return; if (isClaimLimitReached(data, playerId, player)) return; if (isForceLoadLimitReached(data, playerId, player)) return; if (MinecraftForge.EVENT_BUS.post( @@ -175,6 +176,13 @@ private void toggleForceLoad(ClaimChunk msg, EntityPlayerMP player, BLPCSaveHandler.INSTANCE.markDirty(); } + /** Claiming requires a party — solo protection with no party to share/manage it is not supported. */ + private boolean isPartyMissing(UUID playerId, EntityPlayerMP player) { + if (PartyProviderRegistry.get().getPartyId(playerId) != null) return false; + ModNetwork.INSTANCE.sendTo(ClientNotify.claimFailed(ClientNotify.REASON_NO_PARTY, 0, 0), player); + return true; + } + private boolean isClaimLimitReached(ChunkManagerData data, UUID playerId, EntityPlayerMP player) { return isLimitReached(data, playerId, player, data::countClaims, @@ -196,13 +204,17 @@ private boolean isForceLoadLimitReached(ChunkManagerData data, UUID playerId, En /** * Shared shape for claim/force-load limit checks: per-player counting, unless * {@link ModConfig.Claims#additiveLimits additiveLimits} is on and the player has a - * party, in which case usage and the cap are pooled across the party instead. + * party, in which case usage and the cap are pooled across the party instead. Resolves + * the party via the active {@link PartyProviderRegistry} provider (not a raw + * {@code PartyManagerData} lookup) so a BQu-linked player with no BLPC-side {@link Party} + * record of their own still gets pooled with their real party instead of falling back to + * a solo per-player cap. */ private boolean isLimitReached(ChunkManagerData data, UUID playerId, EntityPlayerMP player, Function perPlayerCount, Function perPartyCount, Function perPartyMax, int perPlayerMax, String reason) { Party party = ModConfig.claims.additiveLimits ? - PartyManagerData.getInstance().getPartyByPlayer(playerId) : null; + PartyProviderRegistry.get().getEffectiveParty(playerId) : null; int used = party != null ? perPartyCount.apply(party) : perPlayerCount.apply(playerId); int max = party != null ? perPartyMax.apply(party) : perPlayerMax; if (used >= max) { diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/ClientNotify.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/ClientNotify.java index 9e3544c..62cd72c 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/network/message/ClientNotify.java +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/ClientNotify.java @@ -48,6 +48,7 @@ public class ClientNotify implements IMessage { /** Sub-discriminators for {@link #KIND_CLAIM_FAILED}. */ public static final String REASON_CLAIM_LIMIT = "CLAIM_LIMIT"; public static final String REASON_FORCELOAD_LIMIT = "FORCELOAD_LIMIT"; + public static final String REASON_NO_PARTY = "NO_PARTY"; private int kind; diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/PartyAction.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/PartyAction.java index d80b3f2..6fe11eb 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/network/message/PartyAction.java +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/PartyAction.java @@ -19,6 +19,7 @@ import com.github.gtexpert.blpc.common.network.ModNetwork; import com.github.gtexpert.blpc.common.party.DefaultPartyProvider; import com.github.gtexpert.blpc.common.party.PartyManagerData; +import com.github.gtexpert.blpc.common.waypoint.WaypointManagerData; import io.netty.buffer.ByteBuf; @@ -180,7 +181,9 @@ public void toBytes(ByteBuf buf) { * maps to a single private method below. *

* Authorization: the active provider is re-derived per request from - * {@link PartyManagerData#isBQuLinked} so a malicious client cannot bypass BQu integration. + * {@link IPartyProvider#isLinkedParty} so a malicious client cannot bypass BQu integration — + * this checks the player's current native-party membership rather than a per-player + * flag, so it stays correct for members who joined an already-linked party after the fact. * Role checks happen via {@link #getAdminParty} / {@link #getOrCreateSelfParty} in each * mutating action. *

@@ -200,13 +203,12 @@ public IMessage onMessage(PartyAction msg, MessageContext ctx) { private static void dispatch(PartyAction msg, MessageContext ctx) { EntityPlayerMP player = ctx.getServerHandler().player; IPartyProvider provider = PartyProviderRegistry.get(); - boolean playerBQuLinked = PartyManagerData.getInstance().isBQuLinked(player.getUniqueID()); - // When not BQu-linked, use self-managed provider to avoid accidentally - // creating/modifying BQu parties. + // See class javadoc: isLinkedParty is a live check, not a per-player flag. + boolean playerBQuLinked = provider.isLinkedParty(player.getUniqueID()); IPartyProvider activeProvider = playerBQuLinked ? provider : SELF_PROVIDER; ActionContext c = new ActionContext(player, msg.getStringArg(), provider, SELF_PROVIDER, activeProvider, - playerBQuLinked, new ArrayList<>()); + new ArrayList<>()); boolean success = switch (msg.getAction()) { case PartyAction.ACTION_CREATE -> createParty(c); @@ -270,16 +272,11 @@ private static boolean createParty(ActionContext c) { private static boolean disbandParty(ActionContext c) { UUID playerId = c.player.getUniqueID(); PartyManagerData pm = PartyManagerData.getInstance(); - Party party = pm.getPartyByPlayer(playerId); + Party party = c.provider.getEffectiveParty(playerId); if (party == null) return false; PartyRole role = party.getRole(playerId); - boolean isOwnerOrOp = (role == PartyRole.OWNER) || c.player.canUseCommand(2, ""); - if (!isOwnerOrOp && c.playerBQuLinked) { - String providerRole = c.provider.getRole(playerId); - isOwnerOrOp = PartyRole.fromName(providerRole) == PartyRole.OWNER; - } - if (!isOwnerOrOp) return false; + if (role != PartyRole.OWNER && !c.player.canUseCommand(2, "")) return false; UUID partyId = party.getPartyId(); String partyName = party.getName(); @@ -288,6 +285,7 @@ private static boolean disbandParty(ActionContext c) { List members = new ArrayList<>(party.getMemberUUIDs()); pm.removeParty(partyId); ChunkManagerData.getInstance().releaseAllMemberClaims(members, c.player.world); + WaypointManagerData.getInstance().removeParty(partyId); for (UUID memberId : members) { pm.setBQuLinked(memberId, false); } @@ -447,27 +445,30 @@ private static boolean changeRole(ActionContext c) { private static boolean toggleBQuLink(ActionContext c) { boolean linked = "true".equals(c.stringArg); PartyManagerData pm = PartyManagerData.getInstance(); - Party currentParty = pm.getPartyByPlayer(c.player.getUniqueID()); - if (currentParty != null) { - PartyRole role = currentParty.getRole(c.player.getUniqueID()); - if (role != null && !role.canInvite() && !c.player.canUseCommand(2, "")) { - return false; - } + UUID playerId = c.player.getUniqueID(); + + // c.provider.getRole(), not a raw PartyManagerData lookup — a member who joined after + // the owner's original link action has no BLPC-side Party record of their own. + if (!c.player.canUseCommand(2, "")) { + PartyRole effectiveRole = PartyRole.fromName(c.provider.getRole(playerId)); + if (effectiveRole == null || !effectiveRole.canInvite()) return false; } + if (linked) { + Party currentParty = pm.getPartyByPlayer(playerId); if (currentParty == null) return false; if (!c.provider.ensureNativePartyWithMembers(c.player, currentParty)) return false; - for (UUID memberId : c.provider.getPartyMembers(c.player.getUniqueID())) { + for (UUID memberId : c.provider.getPartyMembers(playerId)) { pm.setBQuLinked(memberId, true); } } else { - if (!pm.isBQuLinked(c.player.getUniqueID())) return false; - for (UUID memberId : c.provider.getPartyMembers(c.player.getUniqueID())) { + if (!c.provider.isLinkedParty(playerId)) return false; + for (UUID memberId : c.provider.getPartyMembers(playerId)) { pm.setBQuLinked(memberId, false); } getOrCreateSelfParty(c.player, c.provider); } - Party party = pm.getPartyByPlayer(c.player.getUniqueID()); + Party party = pm.getPartyByPlayer(playerId); if (party != null) { String event = linked ? ClientNotify.EVENT_BQU_LINKED : ClientNotify.EVENT_BQU_UNLINKED; MinecraftServer srv = c.player.getServer(); @@ -694,18 +695,16 @@ private static final class ActionContext { final IPartyProvider provider; final DefaultPartyProvider selfProvider; final IPartyProvider activeProvider; - final boolean playerBQuLinked; final List pendingNotifications; ActionContext(EntityPlayerMP player, String stringArg, IPartyProvider provider, - DefaultPartyProvider selfProvider, IPartyProvider activeProvider, boolean playerBQuLinked, + DefaultPartyProvider selfProvider, IPartyProvider activeProvider, List pendingNotifications) { this.player = player; this.stringArg = stringArg; this.provider = provider; this.selfProvider = selfProvider; this.activeProvider = activeProvider; - this.playerBQuLinked = playerBQuLinked; this.pendingNotifications = pendingNotifications; } } diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/SyncAllWaypoints.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/SyncAllWaypoints.java new file mode 100644 index 0000000..206a770 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/SyncAllWaypoints.java @@ -0,0 +1,18 @@ +package com.github.gtexpert.blpc.common.network.message; + +import net.minecraft.nbt.NBTTagCompound; + +import com.github.gtexpert.blpc.common.network.NbtMessage; + +/** + * S→C: all of the local player's party-shared waypoints, sent on login. + * Handler: {@code client.network.SyncAllWaypointsClientHandler}. + */ +public class SyncAllWaypoints extends NbtMessage { + + public SyncAllWaypoints() {} + + public SyncAllWaypoints(NBTTagCompound data) { + super(data); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointAction.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointAction.java new file mode 100644 index 0000000..5d41270 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointAction.java @@ -0,0 +1,216 @@ +package com.github.gtexpert.blpc.common.network.message; + +import java.util.UUID; + +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.server.MinecraftServer; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.network.ByteBufUtils; +import net.minecraftforge.fml.common.network.simpleimpl.IMessage; +import net.minecraftforge.fml.common.network.simpleimpl.IMessageHandler; +import net.minecraftforge.fml.common.network.simpleimpl.MessageContext; + +import com.github.gtexpert.blpc.api.party.IPartyProvider; +import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; +import com.github.gtexpert.blpc.api.party.PartyRole; +import com.github.gtexpert.blpc.common.BLPCSaveHandler; +import com.github.gtexpert.blpc.common.ModLog; +import com.github.gtexpert.blpc.common.network.ModNetwork; +import com.github.gtexpert.blpc.common.party.DefaultPartyProvider; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; +import com.github.gtexpert.blpc.common.waypoint.WaypointManagerData; + +import io.netty.buffer.ByteBuf; + +/** + * C→S: Add/update or remove a party-shared waypoint. Sent by the JourneyMap integration when + * {@code WaypointStoreMixin} detects the local player added, edited, or deleted a waypoint on + * JourneyMap's own screen. The server re-broadcasts the change to every other online member of + * the sender's party via {@code WaypointSync}. + *

+ * Authorization: only the party {@link PartyRole#OWNER} may mutate shared waypoints — + * every other member can view them but a mutation attempt is rejected and rolled back (the + * server sends the actor a corrective {@code WaypointSync} reflecting the waypoint's actual + * current state, undoing their local JourneyMap edit/delete). + */ +public class WaypointAction implements IMessage { + + public static final int ACTION_ADD_OR_UPDATE = 0; + public static final int ACTION_REMOVE = 1; + + private int action; + private String waypointId; + private String name; + private int dimension; + private int x, y, z; + private int color; + + public WaypointAction() {} + + public static WaypointAction addOrUpdate(String waypointId, String name, int dimension, int x, int y, int z, + int color) { + var msg = new WaypointAction(); + msg.action = ACTION_ADD_OR_UPDATE; + msg.waypointId = waypointId; + msg.name = name; + msg.dimension = dimension; + msg.x = x; + msg.y = y; + msg.z = z; + msg.color = color; + return msg; + } + + public static WaypointAction remove(String waypointId) { + var msg = new WaypointAction(); + msg.action = ACTION_REMOVE; + msg.waypointId = waypointId; + return msg; + } + + @Override + public void fromBytes(ByteBuf buf) { + action = buf.readByte(); + waypointId = ByteBufUtils.readUTF8String(buf); + if (action == ACTION_ADD_OR_UPDATE) { + name = ByteBufUtils.readUTF8String(buf); + dimension = buf.readInt(); + x = buf.readInt(); + y = buf.readInt(); + z = buf.readInt(); + color = buf.readInt(); + } + } + + @Override + public void toBytes(ByteBuf buf) { + buf.writeByte(action); + ByteBufUtils.writeUTF8String(buf, waypointId); + if (action == ACTION_ADD_OR_UPDATE) { + ByteBufUtils.writeUTF8String(buf, name); + buf.writeInt(dimension); + buf.writeInt(x); + buf.writeInt(y); + buf.writeInt(z); + buf.writeInt(color); + } + } + + public static class Handler implements IMessageHandler { + + private static final DefaultPartyProvider SELF_PROVIDER = new DefaultPartyProvider(); + + /** Matches the PartyWidgets party-name input cap; a shared waypoint name is user-facing text, not data. */ + private static final int MAX_NAME_LENGTH = 32; + /** JourneyMap's own waypoint id is short (name/coords-derived); this only bounds a hostile client. */ + private static final int MAX_WAYPOINT_ID_LENGTH = 128; + /** Backstop against a single (self-appointed OWNER of a solo party) client growing this file/map unbounded. */ + private static final int MAX_WAYPOINTS_PER_PARTY = 200; + + @Override + public IMessage onMessage(WaypointAction message, MessageContext ctx) { + FMLCommonHandler.instance().getWorldThread(ctx.netHandler).addScheduledTask(() -> { + if (message.waypointId == null || message.waypointId.isEmpty() || + message.waypointId.length() > MAX_WAYPOINT_ID_LENGTH) { + return; + } + + EntityPlayerMP player = ctx.getServerHandler().player; + UUID playerId = player.getUniqueID(); + + // Re-derived per request — see PartyAction.Handler#dispatch for why isLinkedParty + // (a live check) is used instead of a per-player flag. + IPartyProvider provider = PartyProviderRegistry.get(); + boolean playerBQuLinked = provider.isLinkedParty(playerId); + IPartyProvider activeProvider = playerBQuLinked ? provider : SELF_PROVIDER; + + // getPartyId(), not PartyManagerData#getPartyByPlayer() — a BQu-linked member who + // joined entirely through BQu's own UI may have no BLPC-side Party record at all, + // and that record's id isn't guaranteed identical across members anyway (see + // BQuPartyProvider#serializeForClient). getPartyId() is. + UUID partyId = activeProvider.getPartyId(playerId); + if (partyId == null) return; + + WaypointManagerData data = WaypointManagerData.getInstance(); + + if (!isOwner(playerId, activeProvider)) { + rollback(data, partyId, message.waypointId, player); + return; + } + + switch (message.action) { + case ACTION_ADD_OR_UPDATE -> { + if (message.name == null || message.name.isEmpty() || message.name.length() > MAX_NAME_LENGTH) { + return; + } + if (data.countWaypoints(partyId) >= MAX_WAYPOINTS_PER_PARTY && + data.getWaypoint(partyId, message.waypointId) == null) { + rollback(data, partyId, message.waypointId, player); + return; + } + var waypoint = new PartyWaypointData(message.waypointId, playerId, message.name, + message.dimension, message.x, message.y, message.z, message.color); + data.setWaypoint(partyId, waypoint); + broadcast(activeProvider, playerId, + WaypointSync.addOrUpdate(waypoint.waypointId, waypoint.ownerUUID, waypoint.name, + waypoint.dimension, waypoint.x, waypoint.y, waypoint.z, waypoint.color)); + } + case ACTION_REMOVE -> { + data.removeWaypoint(partyId, message.waypointId); + broadcast(activeProvider, playerId, WaypointSync.remove(message.waypointId)); + } + default -> ModLog.SYNC.warn("Unknown WaypointAction.action {} from {}", message.action, playerId); + } + BLPCSaveHandler.INSTANCE.markDirty(); + }); + return null; + } + + /** + * Delegates entirely to {@code activeProvider}, never a directly-fetched BLPC-side + * {@link Party}: when BQu-linked, BLPC's mirrored {@code Party} only ever pushes roles + * into BQu (see {@code ensureNativePartyWithMembers}) and never pulls them back, so it + * can silently keep reporting a stale OWNER after ownership changes through BQu's own + * screen. {@code activeProvider} is already resolved to the real source of truth (BQu or + * self-managed) by the caller, so a single {@code getRole} call is correct either way. + */ + private static boolean isOwner(UUID playerId, IPartyProvider activeProvider) { + return PartyRole.fromName(activeProvider.getRole(playerId)) == PartyRole.OWNER; + } + + /** + * Non-owner attempted a mutation: undo their local JourneyMap edit/delete by sending back + * the waypoint's actual current state (or a removal, if it was never shared in the first + * place). + */ + private void rollback(WaypointManagerData data, UUID partyId, String waypointId, EntityPlayerMP actor) { + PartyWaypointData waypoint = data.getWaypoint(partyId, waypointId); + if (waypoint != null) { + ModNetwork.INSTANCE.sendTo( + WaypointSync.addOrUpdate(waypoint.waypointId, waypoint.ownerUUID, waypoint.name, + waypoint.dimension, waypoint.x, waypoint.y, waypoint.z, waypoint.color), + actor); + return; + } + ModNetwork.INSTANCE.sendTo(WaypointSync.remove(waypointId), actor); + } + + /** + * Sends the sync to every other online party member, resolved from the same + * {@code activeProvider} used for authorization — not the possibly-stale BLPC + * {@link Party#getMemberUUIDs()} — so a BQu-linked party's real current membership is used. + */ + private void broadcast(IPartyProvider activeProvider, UUID actorId, WaypointSync sync) { + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + if (server == null) return; + for (UUID memberId : activeProvider.getPartyMembers(actorId)) { + if (memberId.equals(actorId)) continue; + EntityPlayerMP member = server.getPlayerList().getPlayerByUUID(memberId); + if (member != null) { + ModNetwork.INSTANCE.sendTo(sync, member); + } + } + } + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointSync.java b/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointSync.java new file mode 100644 index 0000000..17fd75d --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/network/message/WaypointSync.java @@ -0,0 +1,114 @@ +package com.github.gtexpert.blpc.common.network.message; + +import java.util.UUID; + +import net.minecraftforge.fml.common.network.ByteBufUtils; +import net.minecraftforge.fml.common.network.simpleimpl.IMessage; + +import io.netty.buffer.ByteBuf; + +/** + * S→C: A single party-shared waypoint was added, updated, or removed. + * Handler lives in {@code client.network.WaypointSyncClientHandler}. + */ +public class WaypointSync implements IMessage { + + private int action; + private String waypointId; + private UUID ownerUUID; + private String name; + private int dimension; + private int x, y, z; + private int color; + + public WaypointSync() {} + + public static WaypointSync addOrUpdate(String waypointId, UUID ownerUUID, String name, int dimension, int x, + int y, int z, int color) { + var msg = new WaypointSync(); + msg.action = WaypointAction.ACTION_ADD_OR_UPDATE; + msg.waypointId = waypointId; + msg.ownerUUID = ownerUUID; + msg.name = name; + msg.dimension = dimension; + msg.x = x; + msg.y = y; + msg.z = z; + msg.color = color; + return msg; + } + + public static WaypointSync remove(String waypointId) { + var msg = new WaypointSync(); + msg.action = WaypointAction.ACTION_REMOVE; + msg.waypointId = waypointId; + return msg; + } + + public int getAction() { + return action; + } + + public String getWaypointId() { + return waypointId; + } + + public UUID getOwnerUUID() { + return ownerUUID; + } + + public String getName() { + return name; + } + + public int getDimension() { + return dimension; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZ() { + return z; + } + + public int getColor() { + return color; + } + + @Override + public void fromBytes(ByteBuf buf) { + action = buf.readByte(); + waypointId = ByteBufUtils.readUTF8String(buf); + if (action == WaypointAction.ACTION_ADD_OR_UPDATE) { + ownerUUID = new UUID(buf.readLong(), buf.readLong()); + name = ByteBufUtils.readUTF8String(buf); + dimension = buf.readInt(); + x = buf.readInt(); + y = buf.readInt(); + z = buf.readInt(); + color = buf.readInt(); + } + } + + @Override + public void toBytes(ByteBuf buf) { + buf.writeByte(action); + ByteBufUtils.writeUTF8String(buf, waypointId); + if (action == WaypointAction.ACTION_ADD_OR_UPDATE) { + buf.writeLong(ownerUUID.getMostSignificantBits()); + buf.writeLong(ownerUUID.getLeastSignificantBits()); + ByteBufUtils.writeUTF8String(buf, name); + buf.writeInt(dimension); + buf.writeInt(x); + buf.writeInt(y); + buf.writeInt(z); + buf.writeInt(color); + } + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/party/DefaultPartyProvider.java b/src/main/java/com/github/gtexpert/blpc/common/party/DefaultPartyProvider.java index b82bd3c..abc709d 100644 --- a/src/main/java/com/github/gtexpert/blpc/common/party/DefaultPartyProvider.java +++ b/src/main/java/com/github/gtexpert/blpc/common/party/DefaultPartyProvider.java @@ -64,6 +64,22 @@ public String getRole(UUID playerUUID) { return role != null ? role.name() : null; } + @Override + @Nullable + public UUID getPartyId(UUID playerUUID) { + PartyManagerData data = getPartyData(); + if (data == null) return null; + Party party = data.getPartyByPlayer(playerUUID); + return party != null ? party.getPartyId() : null; + } + + @Override + @Nullable + public Party getEffectiveParty(UUID playerUUID) { + PartyManagerData data = getPartyData(); + return data != null ? data.getPartyByPlayer(playerUUID) : null; + } + @Override @Nullable public Party findByName(String name) { diff --git a/src/main/java/com/github/gtexpert/blpc/common/waypoint/ClientWaypointCache.java b/src/main/java/com/github/gtexpert/blpc/common/waypoint/ClientWaypointCache.java new file mode 100644 index 0000000..b458b11 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/waypoint/ClientWaypointCache.java @@ -0,0 +1,63 @@ +package com.github.gtexpert.blpc.common.waypoint; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Client-side in-memory cache of the local player's party-shared waypoints. + * Populated via {@code WaypointSync} / {@code SyncAllWaypoints} from the server. + */ +public class ClientWaypointCache { + + private static final Map cache = new HashMap<>(); + private static final List changeListeners = new ArrayList<>(); + + public static void addChangeListener(Runnable listener) { + changeListeners.add(listener); + } + + public static void removeChangeListener(Runnable listener) { + changeListeners.remove(listener); + } + + private static void fireChangeListeners() { + for (Runnable listener : new ArrayList<>(changeListeners)) { + listener.run(); + } + } + + public static void update(PartyWaypointData waypoint) { + cache.put(waypoint.waypointId, waypoint); + fireChangeListeners(); + } + + /** + * Replaces the entire cache and fires listeners exactly once. Used for the full login sync — + * calling {@link #update(PartyWaypointData)} per entry would fire the JourneyMap mirror + * listener (which re-scans every waypoint) once per waypoint, an O(n^2) cost on large lists. + */ + public static void loadAll(Collection waypoints) { + cache.clear(); + for (PartyWaypointData waypoint : waypoints) { + cache.put(waypoint.waypointId, waypoint); + } + fireChangeListeners(); + } + + public static void remove(String waypointId) { + cache.remove(waypointId); + fireChangeListeners(); + } + + public static void clearAll() { + cache.clear(); + } + + public static Collection getAll() { + return Collections.unmodifiableCollection(cache.values()); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/waypoint/PartyWaypointData.java b/src/main/java/com/github/gtexpert/blpc/common/waypoint/PartyWaypointData.java new file mode 100644 index 0000000..553698b --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/waypoint/PartyWaypointData.java @@ -0,0 +1,53 @@ +package com.github.gtexpert.blpc.common.waypoint; + +import java.util.UUID; + +import net.minecraft.nbt.NBTTagCompound; + +/** + * A single party-shared waypoint. {@code waypointId} is the JourneyMap-side identifier of the + * waypoint on the creator's client ({@code journeymap.client.model.Waypoint#getId()}), reused + * as the shared key so add/update/remove messages from any member address the same entry. + */ +public class PartyWaypointData { + + public final String waypointId; + public final UUID ownerUUID; + public final String name; + public final int dimension; + public final int x, y, z; + public final int color; + + public PartyWaypointData(String waypointId, UUID ownerUUID, String name, int dimension, int x, int y, int z, + int color) { + this.waypointId = waypointId; + this.ownerUUID = ownerUUID; + this.name = name; + this.dimension = dimension; + this.x = x; + this.y = y; + this.z = z; + this.color = color; + } + + public NBTTagCompound toNBT() { + NBTTagCompound tag = new NBTTagCompound(); + tag.setString("id", waypointId); + tag.setUniqueId("owner", ownerUUID); + tag.setString("name", name); + tag.setInteger("dim", dimension); + tag.setInteger("x", x); + tag.setInteger("y", y); + tag.setInteger("z", z); + tag.setInteger("color", color); + return tag; + } + + public static PartyWaypointData fromNBT(NBTTagCompound tag) { + UUID owner = tag.getUniqueId("owner"); + if (owner == null || owner.equals(new UUID(0L, 0L))) return null; + return new PartyWaypointData( + tag.getString("id"), owner, tag.getString("name"), tag.getInteger("dim"), + tag.getInteger("x"), tag.getInteger("y"), tag.getInteger("z"), tag.getInteger("color")); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/common/waypoint/WaypointManagerData.java b/src/main/java/com/github/gtexpert/blpc/common/waypoint/WaypointManagerData.java new file mode 100644 index 0000000..cf97d46 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/common/waypoint/WaypointManagerData.java @@ -0,0 +1,73 @@ +package com.github.gtexpert.blpc.common.waypoint; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server-side storage for party-shared waypoints. Singleton, persisted by + * {@link com.github.gtexpert.blpc.common.BLPCSaveHandler}. Waypoints are grouped per party; + * a party with no waypoints has no entry in {@link #partyWaypoints}. + */ +public class WaypointManagerData { + + private static volatile WaypointManagerData instance; + + private final Map> partyWaypoints = new ConcurrentHashMap<>(); + + public static synchronized WaypointManagerData getInstance() { + if (instance == null) { + instance = new WaypointManagerData(); + } + return instance; + } + + public static synchronized void reset() { + instance = new WaypointManagerData(); + } + + public Collection getWaypoints(UUID partyId) { + Map waypoints = partyWaypoints.get(partyId); + return waypoints == null ? Collections.emptyList() : Collections.unmodifiableCollection(waypoints.values()); + } + + public PartyWaypointData getWaypoint(UUID partyId, String waypointId) { + Map waypoints = partyWaypoints.get(partyId); + return waypoints == null ? null : waypoints.get(waypointId); + } + + public int countWaypoints(UUID partyId) { + Map waypoints = partyWaypoints.get(partyId); + return waypoints == null ? 0 : waypoints.size(); + } + + public void setWaypoint(UUID partyId, PartyWaypointData waypoint) { + partyWaypoints.computeIfAbsent(partyId, k -> new ConcurrentHashMap<>()) + .put(waypoint.waypointId, waypoint); + } + + public void removeWaypoint(UUID partyId, String waypointId) { + Map waypoints = partyWaypoints.get(partyId); + if (waypoints == null) return; + waypoints.remove(waypointId); + if (waypoints.isEmpty()) { + partyWaypoints.remove(partyId); + } + } + + /** Removes every waypoint belonging to a disbanded party. */ + public void removeParty(UUID partyId) { + partyWaypoints.remove(partyId); + } + + public Map> getAllForSave() { + return Collections.unmodifiableMap(partyWaypoints); + } + + public void loadParty(UUID partyId, Map waypoints) { + if (waypoints.isEmpty()) return; + partyWaypoints.put(partyId, new ConcurrentHashMap<>(waypoints)); + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/core/ChunkProtectionHandler.java b/src/main/java/com/github/gtexpert/blpc/core/ChunkProtectionHandler.java index 6e22651..d14cafe 100644 --- a/src/main/java/com/github/gtexpert/blpc/core/ChunkProtectionHandler.java +++ b/src/main/java/com/github/gtexpert/blpc/core/ChunkProtectionHandler.java @@ -26,12 +26,12 @@ import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; import com.github.gtexpert.blpc.api.party.TrustAction; import com.github.gtexpert.blpc.api.party.TrustLevel; import com.github.gtexpert.blpc.common.ModConfig; import com.github.gtexpert.blpc.common.chunk.ChunkManagerData; import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; -import com.github.gtexpert.blpc.common.party.PartyManagerData; /** * Central Forge event handler for chunk protection. @@ -52,9 +52,15 @@ private static boolean isChunkClaimed(int chunkX, int chunkZ) { return ChunkManagerData.getInstance().getClaim(chunkX, chunkZ) != null; } + /** + * Resolves the claim owner's effective party via the active {@link PartyProviderRegistry} + * provider rather than reading {@code PartyManagerData} directly — a BQu-linked owner who + * joined their party purely through BQu's own UI has no BLPC-side {@link Party} record, and + * a raw {@code PartyManagerData} lookup would incorrectly resolve to "no party" for them. + */ @Nullable private static Party getPartyForClaim(ClaimedChunkData claim) { - return PartyManagerData.getInstance().getPartyByPlayer(claim.ownerUUID); + return PartyProviderRegistry.get().getEffectiveParty(claim.ownerUUID); } private static boolean isNameInList(@Nullable ResourceLocation name, String[] list) { @@ -106,7 +112,7 @@ private static boolean canPlayerActAt(@Nullable EntityPlayer player, BlockPos po if (party == null) return false; - var playerParty = PartyManagerData.getInstance().getPartyByPlayer(player.getUniqueID()); + var playerParty = PartyProviderRegistry.get().getEffectiveParty(player.getUniqueID()); var playerPartyId = playerParty != null ? playerParty.getPartyId() : null; TrustLevel effectiveLevel = party.getEffectiveTrustLevel(player.getUniqueID(), playerPartyId); if (effectiveLevel == null) return false; // Enemy: null encodes "no trust" diff --git a/src/main/java/com/github/gtexpert/blpc/core/ChunkTransitHandler.java b/src/main/java/com/github/gtexpert/blpc/core/ChunkTransitHandler.java index 6dd08f3..790420f 100644 --- a/src/main/java/com/github/gtexpert/blpc/core/ChunkTransitHandler.java +++ b/src/main/java/com/github/gtexpert/blpc/core/ChunkTransitHandler.java @@ -12,14 +12,15 @@ import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.fml.common.gameevent.TickEvent; +import com.github.gtexpert.blpc.api.party.IPartyProvider; import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; import com.github.gtexpert.blpc.api.party.RelationType; import com.github.gtexpert.blpc.common.ModConfig; import com.github.gtexpert.blpc.common.chunk.ChunkManagerData; import com.github.gtexpert.blpc.common.chunk.ClaimedChunkData; import com.github.gtexpert.blpc.common.network.ModNetwork; import com.github.gtexpert.blpc.common.network.message.ClientNotify; -import com.github.gtexpert.blpc.common.party.PartyManagerData; /** * Detects when players cross claimed chunk boundaries and: @@ -50,25 +51,25 @@ public static void onPlayerTick(TickEvent.PlayerTickEvent event) { Long prev = previousChunk.put(playerId, packed); if (prev != null && prev == packed) { // Same chunk — only handle periodic area effects - if (ModConfig.Defaults.enableAreaEffects && player.ticksExisted % EFFECT_TICK_INTERVAL == 0) { + if (ModConfig.fairPlay.enableAreaEffects && player.ticksExisted % EFFECT_TICK_INTERVAL == 0) { applyAreaEffects(player, cx, cz); } return; } ChunkManagerData chunkData = ChunkManagerData.getInstance(); - PartyManagerData partyData = PartyManagerData.getInstance(); + IPartyProvider activeProvider = PartyProviderRegistry.get(); if (prev != null) { int prevX = unpackX(prev); int prevZ = unpackZ(prev); ClaimedChunkData prevClaim = chunkData.getClaim(prevX, prevZ); if (prevClaim != null) { - Party prevParty = partyData.getPartyByPlayer(prevClaim.ownerUUID); + Party prevParty = activeProvider.getEffectiveParty(prevClaim.ownerUUID); if (prevParty != null) { - RelationType rel = resolveRelation(prevParty, player); + RelationType rel = resolveRelation(prevParty, player, activeProvider); if (rel != RelationType.NONE) { - if (ModConfig.Defaults.enableTransitNotify) { + if (ModConfig.fairPlay.enableTransitNotify) { sendNotifications(prevParty, player, rel, false); } if (rel == RelationType.ENEMY) { @@ -81,21 +82,21 @@ public static void onPlayerTick(TickEvent.PlayerTickEvent event) { ClaimedChunkData curClaim = chunkData.getClaim(cx, cz); if (curClaim != null) { - Party curParty = partyData.getPartyByPlayer(curClaim.ownerUUID); + Party curParty = activeProvider.getEffectiveParty(curClaim.ownerUUID); if (curParty != null) { - RelationType rel = resolveRelation(curParty, player); + RelationType rel = resolveRelation(curParty, player, activeProvider); if (rel != RelationType.NONE) { - if (ModConfig.Defaults.enableTransitNotify) { + if (ModConfig.fairPlay.enableTransitNotify) { sendNotifications(curParty, player, rel, true); } - if (rel == RelationType.ENEMY && ModConfig.Defaults.enableAreaEffects) { + if (rel == RelationType.ENEMY && ModConfig.fairPlay.enableAreaEffects) { onEnemyEnter(curParty.getPartyId(), playerId); } } } } - if (ModConfig.Defaults.enableAreaEffects) { + if (ModConfig.fairPlay.enableAreaEffects) { applyAreaEffects(player, cx, cz); } } @@ -110,13 +111,14 @@ public static void onPlayerLogout(UUID playerId) { }); } - private static RelationType resolveRelation(Party claimParty, EntityPlayerMP player) { + private static RelationType resolveRelation(Party claimParty, EntityPlayerMP player, + IPartyProvider activeProvider) { UUID playerId = player.getUniqueID(); if (claimParty.isMember(playerId)) { return RelationType.MEMBER; } - Party playerParty = PartyManagerData.getInstance().getPartyByPlayer(playerId); + Party playerParty = activeProvider.getEffectiveParty(playerId); if (playerParty == null) return RelationType.NONE; UUID playerPartyId = playerParty.getPartyId(); @@ -162,14 +164,14 @@ private static void onEnemyLeave(UUID partyId, UUID enemyId, EntityPlayerMP enem private static void applyAreaEffects(EntityPlayerMP player, int cx, int cz) { ChunkManagerData chunkData = ChunkManagerData.getInstance(); - PartyManagerData partyData = PartyManagerData.getInstance(); + IPartyProvider activeProvider = PartyProviderRegistry.get(); ClaimedChunkData claim = chunkData.getClaim(cx, cz); if (claim == null) return; - Party claimParty = partyData.getPartyByPlayer(claim.ownerUUID); + Party claimParty = activeProvider.getEffectiveParty(claim.ownerUUID); if (claimParty == null) return; - RelationType rel = resolveRelation(claimParty, player); + RelationType rel = resolveRelation(claimParty, player, activeProvider); if (rel == RelationType.ENEMY) { player.addPotionEffect(new PotionEffect( diff --git a/src/main/java/com/github/gtexpert/blpc/core/CoreEventHandler.java b/src/main/java/com/github/gtexpert/blpc/core/CoreEventHandler.java index 266adb2..870e36c 100644 --- a/src/main/java/com/github/gtexpert/blpc/core/CoreEventHandler.java +++ b/src/main/java/com/github/gtexpert/blpc/core/CoreEventHandler.java @@ -3,6 +3,7 @@ import java.util.Set; import java.util.UUID; +import net.minecraft.client.Minecraft; import net.minecraft.server.MinecraftServer; import net.minecraft.world.WorldServer; import net.minecraftforge.event.world.WorldEvent; @@ -15,6 +16,8 @@ import net.minecraftforge.fml.relauncher.SideOnly; import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.PartyProviderRegistry; +import com.github.gtexpert.blpc.client.cache.ClientCachePersistence; import com.github.gtexpert.blpc.common.BLPCSaveHandler; import com.github.gtexpert.blpc.common.ModConfig; import com.github.gtexpert.blpc.common.chunk.ChunkManagerData; @@ -23,6 +26,7 @@ import com.github.gtexpert.blpc.common.chunk.TicketManager; import com.github.gtexpert.blpc.common.party.ClientPartyCache; import com.github.gtexpert.blpc.common.party.PartyManagerData; +import com.github.gtexpert.blpc.common.waypoint.ClientWaypointCache; public class CoreEventHandler { @@ -50,7 +54,7 @@ public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { if (ModConfig.claims.allowOfflineChunkLoading) return; UUID playerId = event.player.getUniqueID(); - Party party = PartyManagerData.getInstance().getPartyByPlayer(playerId); + Party party = PartyProviderRegistry.get().getEffectiveParty(playerId); if (party == null) return; MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); @@ -72,10 +76,33 @@ public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { @SideOnly(Side.CLIENT) public static class ClientHandler { + @SubscribeEvent + public void onClientConnect(FMLNetworkEvent.ClientConnectedToServerEvent event) { + // Forge posts this from the Netty I/O thread, not the client main thread — every + // other handler in this codebase (see MainThreadMessageHandler) hops onto the main + // thread before touching ClientClaimCache/ClientPartyCache, which are plain + // non-thread-safe collections. + Minecraft.getMinecraft().addScheduledTask(() -> { + // Load before registering listeners so the load itself doesn't trigger a + // redundant save. + ClientCachePersistence.loadForCurrentServer(); + ClientCachePersistence.register(); + }); + } + @SubscribeEvent public void onClientDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent event) { - ClientClaimCache.clearAll(); - ClientPartyCache.clearAll(); + // Same off-main-thread caveat as onClientConnect above (abnormal disconnects such as + // a timeout can fire this from the Netty thread too). + Minecraft.getMinecraft().addScheduledTask(() -> { + ClientCachePersistence.saveNow(); + // clearAll() below already drops every registered listener (including these), + // but unregister explicitly so this stays correct even if that changes. + ClientCachePersistence.unregister(); + ClientClaimCache.clearAll(); + ClientPartyCache.clearAll(); + ClientWaypointCache.clearAll(); + }); } } } diff --git a/src/main/java/com/github/gtexpert/blpc/integration/bqu/BQuPartyProvider.java b/src/main/java/com/github/gtexpert/blpc/integration/bqu/BQuPartyProvider.java index c6ea9df..1a4f698 100644 --- a/src/main/java/com/github/gtexpert/blpc/integration/bqu/BQuPartyProvider.java +++ b/src/main/java/com/github/gtexpert/blpc/integration/bqu/BQuPartyProvider.java @@ -30,6 +30,7 @@ import betterquesting.api.enums.EnumPartyStatus; import betterquesting.api.properties.NativeProps; import betterquesting.api.questing.party.IParty; +import betterquesting.api2.storage.DBEntry; import betterquesting.network.handlers.NetPartySync; import betterquesting.questing.party.PartyInvitations; import betterquesting.questing.party.PartyManager; @@ -87,11 +88,55 @@ public String getRole(UUID playerUUID) { return fallback.getRole(playerUUID); } + /** + * Derived from BQu's own integer party id, so it's identical for every member regardless of + * whether any of them has ever had a BLPC-side {@link Party} record created (unlike + * {@link #serializeForClient}'s display id, which prefers a member's self-managed party id + * when one happens to exist). + */ + @Override + @Nullable + public UUID getPartyId(UUID playerUUID) { + var entry = PartyManager.INSTANCE.getParty(playerUUID); + if (entry != null) return Party.uuidFromIntId(entry.getID()); + return fallback.getPartyId(playerUUID); + } + + /** + * Looks up the player's BQu party directly (O(1) via BQu's own player index) rather than + * scanning every party like {@link #serializeForClient}, since this is called from hot + * per-action checks (block break/interact, claim limits, chunk transit). + */ + @Override + @Nullable + public Party getEffectiveParty(UUID playerUUID) { + var entry = PartyManager.INSTANCE.getParty(playerUUID); + if (entry != null) return buildMergedParty(entry); + return fallback.getEffectiveParty(playerUUID); + } + @Override public boolean hasNativeParty(UUID playerUUID) { return PartyManager.INSTANCE.getParty(playerUUID) != null; } + /** + * Checks the player's current BQu party membership for any linked member, rather + * than a per-player flag — a member who joined this same BQu party after the owner's link + * action (typically through BQu's own party screen) is recognized without requiring the flag + * to be separately propagated to them. + */ + @Override + public boolean isLinkedParty(UUID playerUUID) { + var entry = PartyManager.INSTANCE.getParty(playerUUID); + if (entry == null) return false; + PartyManagerData pmData = PartyManagerData.getInstance(); + for (UUID memberId : entry.getValue().getMembers()) { + if (pmData.isBQuLinked(memberId)) return true; + } + return false; + } + @Override public boolean ensureNativePartyWithMembers(EntityPlayerMP owner, Party blpcParty) { UUID ownerId = QuestingAPI.getQuestingUUID(owner); @@ -308,6 +353,48 @@ public void syncToPlayer(EntityPlayerMP player) { ModNetwork.INSTANCE.sendTo(new PartySync(serializeForClient()), player); } + /** + * Merges one BQu party's live membership with settings/relations copied from whichever + * member happens to have a BLPC-side {@link Party} record (preferring the BQu owner's, so + * the owner's protection settings win if members disagree). Shared by + * {@link #serializeForClient} (client display) and {@link #getEffectiveParty} (server-side + * authoritative checks) so both stay in sync as this logic evolves. + */ + private Party buildMergedParty(DBEntry entry) { + IParty bqParty = entry.getValue(); + PartyManagerData pmData = PartyManagerData.getInstance(); + + UUID blpcPartyId = null; + Party ownerSelfParty = null; + Party fallbackSelfParty = null; + for (UUID memberId : bqParty.getMembers()) { + Party selfParty = pmData.getPartyByPlayer(memberId); + if (selfParty != null) { + if (blpcPartyId == null) blpcPartyId = selfParty.getPartyId(); + EnumPartyStatus status = bqParty.getStatus(memberId); + if (status == EnumPartyStatus.OWNER) { + ownerSelfParty = selfParty; + blpcPartyId = selfParty.getPartyId(); + } else if (fallbackSelfParty == null) { + fallbackSelfParty = selfParty; + } + } + } + if (blpcPartyId == null) blpcPartyId = Party.uuidFromIntId(entry.getID()); + + String bqName = bqParty.getProperties().getProperty(NativeProps.NAME); + if (bqName == null) bqName = "Party " + blpcPartyId.toString().substring(0, 8); + Party party = new Party(blpcPartyId, bqName, 0L); + for (UUID memberId : bqParty.getMembers()) { + party.addMember(memberId, mapRole(bqParty.getStatus(memberId))); + } + Party sourceSelfParty = ownerSelfParty != null ? ownerSelfParty : fallbackSelfParty; + if (sourceSelfParty != null) { + party.copySettingsFrom(sourceSelfParty); + } + return party; + } + @Override public NBTTagCompound serializeForClient() { NBTTagList list = new NBTTagList(); @@ -328,36 +415,8 @@ public NBTTagCompound serializeForClient() { } if (!hasLinkedMember) continue; - UUID blpcPartyId = null; - Party ownerSelfParty = null; - Party fallbackSelfParty = null; - for (UUID memberId : bqParty.getMembers()) { - Party selfParty = pmData.getPartyByPlayer(memberId); - if (selfParty != null) { - if (blpcPartyId == null) blpcPartyId = selfParty.getPartyId(); - EnumPartyStatus status = bqParty.getStatus(memberId); - if (status == EnumPartyStatus.OWNER) { - ownerSelfParty = selfParty; - blpcPartyId = selfParty.getPartyId(); - } else if (fallbackSelfParty == null) { - fallbackSelfParty = selfParty; - } - } - } - if (blpcPartyId == null) blpcPartyId = Party.uuidFromIntId(entry.getID()); - - String bqName = bqParty.getProperties().getProperty(NativeProps.NAME); - if (bqName == null) bqName = "Party " + blpcPartyId.toString().substring(0, 8); - Party party = new Party(blpcPartyId, bqName, 0L); - for (UUID memberId : bqParty.getMembers()) { - EnumPartyStatus status = bqParty.getStatus(memberId); - party.addMember(memberId, mapRole(status)); - bquMembers.add(memberId); - } - Party sourceSelfParty = ownerSelfParty != null ? ownerSelfParty : fallbackSelfParty; - if (sourceSelfParty != null) { - party.copySettingsFrom(sourceSelfParty); - } + Party party = buildMergedParty(entry); + bquMembers.addAll(bqParty.getMembers()); party.resolvePlayerNames(PartyManagerData.getInstance()::getParty); list.appendTag(party.toSyncNBT()); } @@ -381,8 +440,17 @@ public NBTTagCompound serializeForClient() { NBTTagCompound root = new NBTTagCompound(); root.setTag("parties", list); - // Included even when empty so the client clears stale bquLinked state. - root.setTag("bquLinked", selfData.getTagList("bquLinked", Constants.NBT.TAG_COMPOUND)); + // Built from bquMembers (live BQu membership of every linked party), not the raw + // bquLinkedPlayers flag set — a member who joined a linked party after the owner's + // link action has no flag of their own but is still genuinely linked. Included even + // when empty so the client clears stale bquLinked state. + NBTTagList bquLinkedList = new NBTTagList(); + for (UUID memberId : bquMembers) { + NBTTagCompound tag = new NBTTagCompound(); + tag.setUniqueId("uuid", memberId); + bquLinkedList.appendTag(tag); + } + root.setTag("bquLinked", bquLinkedList); return root; } diff --git a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapClientConfig.java b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapClientConfig.java index b11b588..d1fd775 100644 --- a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapClientConfig.java +++ b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapClientConfig.java @@ -14,6 +14,7 @@ public final class JMapClientConfig { private static boolean showClaimOverlays = true; + private static boolean waypointSharingEnabled = true; private JMapClientConfig() {} @@ -25,4 +26,17 @@ public static boolean isShowClaimOverlays() { public static void setShowClaimOverlays(boolean value) { showClaimOverlays = value; } + + /** + * Whether party-shared waypoints are synced. Only the party OWNER's edits are ever sent + * (see {@code WaypointAction} javadoc) — this toggle just lets any member opt out of + * receiving/mirroring them locally. + */ + public static boolean isWaypointSharingEnabled() { + return waypointSharingEnabled; + } + + public static void setWaypointSharingEnabled(boolean value) { + waypointSharingEnabled = value; + } } diff --git a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapModule.java b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapModule.java index 7e02df0..99b212c 100644 --- a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapModule.java +++ b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapModule.java @@ -23,9 +23,10 @@ * JourneyMap integration module. *

* Loaded only when {@code journeymap} is installed. On the client, hooks the - * claim sync stream so JourneyMap displays per-chunk claim overlays. The - * sync handler is re-registered on every reconnect to recover from JourneyMap - * resetting its overlay state on disconnect. + * claim sync stream so JourneyMap displays per-chunk claim overlays, and (when the + * Mixin-based waypoint bridge is active) mirrors party-shared waypoints onto the local + * JourneyMap waypoint store. Sync handlers are re-registered on every reconnect to recover + * from JourneyMap resetting its overlay state on disconnect. */ @TModule( moduleID = Modules.MODULE_JMAP, @@ -38,12 +39,18 @@ public class JMapModule extends IntegrationSubmodule { @SideOnly(Side.CLIENT) private JMapClaimSyncHandler syncHandler; + @SideOnly(Side.CLIENT) + private JMapWaypointSyncHandler waypointSyncHandler; + @Override public void init(FMLInitializationEvent event) { if (event.getSide().isClient()) { syncHandler = new JMapClaimSyncHandler(); syncHandler.register(); + waypointSyncHandler = new JMapWaypointSyncHandler(); + waypointSyncHandler.register(); MinecraftForge.EVENT_BUS.register(this); + MinecraftForge.EVENT_BUS.register(JMapWaypointOutgoing.class); IntegrationPanelRegistry.register( "blpc.addons.journeymap", null, () -> true, JMapSettingsPanel::build); } @@ -55,6 +62,9 @@ public void onClientDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEven if (syncHandler != null) { syncHandler.unregister(); } + if (waypointSyncHandler != null) { + waypointSyncHandler.unregister(); + } } @SideOnly(Side.CLIENT) @@ -64,6 +74,10 @@ public void onClientConnect(FMLNetworkEvent.ClientConnectedToServerEvent event) syncHandler.unregister(); syncHandler.register(); } + if (waypointSyncHandler != null) { + waypointSyncHandler.unregister(); + waypointSyncHandler.register(); + } } @NotNull diff --git a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapPlugin.java b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapPlugin.java index 0c8d8df..d163a3b 100644 --- a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapPlugin.java +++ b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapPlugin.java @@ -12,6 +12,7 @@ import java.util.UUID; import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.I18n; import net.minecraft.util.math.BlockPos; import com.github.gtexpert.blpc.Tags; @@ -46,6 +47,9 @@ public class JMapPlugin implements IClientPlugin { private static final int COLOR_OTHER = 0xFF0000; private static final float FILL_OPACITY = 0.35f; private static final float STROKE_OPACITY = 0.6f; + private static final float STROKE_WIDTH = 1.5f; + private static final float FORCE_LOADED_STROKE_OPACITY = 1.0f; + private static final float FORCE_LOADED_STROKE_WIDTH = 3.0f; private IClientAPI api; private static JMapPlugin instance; @@ -156,17 +160,17 @@ private void buildOwnerRegions(List claims, UUID playerUUID, i String key = sample.ownerUUID + ":" + minX + "," + minZ; currentKeys.add(key); String title = buildTitle(sample, allForceLoaded); - showRegion(key, dimension, outer, loops, areaColor, textColor, title); + showRegion(key, dimension, outer, loops, areaColor, textColor, title, allForceLoaded); } } private void showRegion(String key, int dimension, MapPolygon outer, List holes, - int areaColor, int textColor, String title) { + int areaColor, int textColor, String title, boolean forceLoaded) { PolygonOverlay existing = activeOverlays.get(key); if (existing != null) { existing.setOuterArea(outer); existing.setHoles(holes.isEmpty() ? null : holes); - existing.setShapeProperties(createShapeProperties(areaColor)); + existing.setShapeProperties(createShapeProperties(areaColor, forceLoaded)); existing.setTitle(title); existing.getTextProperties().setColor(textColor); try { @@ -176,7 +180,7 @@ private void showRegion(String key, int dimension, MapPolygon outer, List { + JMapClientConfig.setWaypointSharingEnabled(val); + JMapWaypointSyncHandler.refreshFromSettings(); + }), + "blpc.addons.journeymap.waypoints_off", "blpc.addons.journeymap.waypoints_on") + .addTooltipLine(IKey.lang("blpc.addons.journeymap.waypoints_tooltip"))); PartyWidgets.addList(panel, list); return panel; diff --git a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointOutgoing.java b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointOutgoing.java new file mode 100644 index 0000000..1b24baa --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointOutgoing.java @@ -0,0 +1,105 @@ +package com.github.gtexpert.blpc.integration.jmap; + +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import net.minecraft.client.Minecraft; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.api.party.Party; +import com.github.gtexpert.blpc.api.party.PartyRole; +import com.github.gtexpert.blpc.common.network.ModNetwork; +import com.github.gtexpert.blpc.common.network.message.WaypointAction; +import com.github.gtexpert.blpc.common.party.ClientPartyCache; + +import journeymap.client.model.Waypoint; + +/** + * Detects local waypoint changes ({@link WaypointStoreMixin}) and forwards them to the server + * so they reach the rest of the local player's party. Not registered/loaded unless JourneyMap + * is present (gated by {@code mixins.blpc.journeymap.json}). + *

+ * {@code WaypointEditor.save()} always calls {@code WaypointStore.remove(original)} then + * {@code WaypointStore.save(edited)}, even for a brand-new waypoint. Without debouncing, every + * edit would send a spurious REMOVE immediately followed by an ADD_OR_UPDATE. Instead, a + * detected remove is held until the end of the current client tick; if a save for the same + * waypoint arrives before then, the pending remove is dropped and only the update is sent. + * Removes are held per waypoint id (not a single slot), so deleting several waypoints within + * one tick still forwards every one of them. + */ +@SideOnly(Side.CLIENT) +public final class JMapWaypointOutgoing { + + private static volatile boolean applyingRemoteChange = false; + private static final Set pendingRemoveIds = ConcurrentHashMap.newKeySet(); + + private JMapWaypointOutgoing() {} + + /** Set by {@link JMapWaypointSyncHandler} while it writes incoming shared waypoints locally. */ + static void beginApplyingRemoteChange() { + applyingRemoteChange = true; + } + + static void endApplyingRemoteChange() { + applyingRemoteChange = false; + } + + public static void onLocalSave(Waypoint waypoint) { + if (applyingRemoteChange) return; + if (!JMapClientConfig.isWaypointSharingEnabled()) return; + if (waypoint.getType() == Waypoint.Type.Death) return; + if (!isPartyOwner()) return; + + // Drop only this waypoint's pending remove, leaving other waypoints' removes queued. + pendingRemoveIds.remove(waypoint.getId()); + + Integer color = waypoint.getColor(); + // A waypoint can span multiple dimensions in JourneyMap's UI, but BLPC's wire format + // only carries one; picking any single entry is fine since shared waypoints are almost + // always dimension-specific in practice. + int dimension = waypoint.getDimensions().isEmpty() ? 0 : waypoint.getDimensions().iterator().next(); + ModNetwork.INSTANCE.sendToServer(WaypointAction.addOrUpdate( + waypoint.getId(), waypoint.getName(), dimension, + waypoint.getX(), waypoint.getY(), waypoint.getZ(), color != null ? color : 0xFFFFFF)); + } + + public static void onLocalRemove(Waypoint waypoint) { + if (applyingRemoteChange) return; + if (!JMapClientConfig.isWaypointSharingEnabled()) return; + if (waypoint.getType() == Waypoint.Type.Death) return; + if (!isPartyOwner()) return; + + // Held until end-of-tick — see class javadoc. A same-waypoint save() arriving first + // clears this via onLocalSave, so a pure edit never sends a REMOVE at all. + pendingRemoveIds.add(waypoint.getId()); + } + + /** + * Client-side mirror of the server's authorization check (see {@code WaypointAction} + * javadoc) — only the party OWNER's edits are sent. This is purely to avoid pointless + * traffic and rollback flicker for non-owners; the server enforces the real check + * regardless of what a modified client might send. + */ + private static boolean isPartyOwner() { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null) return false; + Party party = ClientPartyCache.getPartyByPlayer(mc.player.getUniqueID()); + return party != null && party.getRole(mc.player.getUniqueID()) == PartyRole.OWNER; + } + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + if (pendingRemoveIds.isEmpty()) return; + // Drain via iterator.remove() so a remove queued mid-drain isn't lost. + for (Iterator it = pendingRemoveIds.iterator(); it.hasNext();) { + String toRemove = it.next(); + it.remove(); + ModNetwork.INSTANCE.sendToServer(WaypointAction.remove(toRemove)); + } + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointSyncHandler.java b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointSyncHandler.java new file mode 100644 index 0000000..6a3236d --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/integration/jmap/JMapWaypointSyncHandler.java @@ -0,0 +1,89 @@ +package com.github.gtexpert.blpc.integration.jmap; + +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.util.math.BlockPos; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import com.github.gtexpert.blpc.Tags; +import com.github.gtexpert.blpc.common.waypoint.ClientWaypointCache; +import com.github.gtexpert.blpc.common.waypoint.PartyWaypointData; + +import journeymap.client.model.Waypoint; +import journeymap.client.waypoint.WaypointStore; + +/** + * Mirrors {@link ClientWaypointCache} (party-shared waypoints synced from the server) onto the + * local JourneyMap waypoint store, so every online party member sees the same shared markers on + * their own map. + *

+ * A waypoint built from {@code journeymap.client.api.display.Waypoint(BLPC_MODID, waypointId, + * ...)} always ends up with {@code getId() == "blpc:" + waypointId} (confirmed from JourneyMap's + * {@code Waypoint.getGuid()}: {@code origin + ":" + displayId}), so shared waypoints can be + * reliably matched and cleaned up by id without needing a separate id-mapping table. + */ +@SideOnly(Side.CLIENT) +public class JMapWaypointSyncHandler { + + private static JMapWaypointSyncHandler instance; + + private final Runnable listener = this::onCacheChanged; + + public void register() { + instance = this; + ClientWaypointCache.addChangeListener(listener); + } + + public void unregister() { + ClientWaypointCache.removeChangeListener(listener); + if (instance == this) instance = null; + } + + /** Re-applies the mirror after a settings toggle (see {@link JMapSettingsPanel}). */ + static void refreshFromSettings() { + if (instance != null) instance.onCacheChanged(); + } + + private void onCacheChanged() { + JMapWaypointOutgoing.beginApplyingRemoteChange(); + try { + applyToJourneyMap(); + } finally { + JMapWaypointOutgoing.endApplyingRemoteChange(); + } + } + + private void applyToJourneyMap() { + Map mirrored = new HashMap<>(); + for (Waypoint wp : WaypointStore.INSTANCE.getAll()) { + if (Tags.MODID.equals(wp.getOrigin())) { + mirrored.put(wp.getId(), wp); + } + } + + if (!JMapClientConfig.isWaypointSharingEnabled()) { + // Disabled locally: drop anything BLPC previously mirrored, mirror nothing new. + for (Waypoint leftover : mirrored.values()) { + WaypointStore.INSTANCE.remove(leftover); + } + return; + } + + for (PartyWaypointData shared : ClientWaypointCache.getAll()) { + var apiWaypoint = new journeymap.client.api.display.Waypoint(Tags.MODID, shared.waypointId, shared.name, + shared.dimension, new BlockPos(shared.x, shared.y, shared.z)); + apiWaypoint.setColor(shared.color); + Waypoint internal = new Waypoint(apiWaypoint); + WaypointStore.INSTANCE.save(internal); + mirrored.remove(internal.getId()); + } + + // Anything left is a BLPC-origin waypoint no longer in the cache (removed by the owner, + // or the local player is no longer in that party) — clean it up. + for (Waypoint leftover : mirrored.values()) { + WaypointStore.INSTANCE.remove(leftover); + } + } +} diff --git a/src/main/java/com/github/gtexpert/blpc/mixins/BLPCMixinLoader.java b/src/main/java/com/github/gtexpert/blpc/mixins/BLPCMixinLoader.java index 93ced4f..1d5fff7 100644 --- a/src/main/java/com/github/gtexpert/blpc/mixins/BLPCMixinLoader.java +++ b/src/main/java/com/github/gtexpert/blpc/mixins/BLPCMixinLoader.java @@ -20,6 +20,7 @@ public class BLPCMixinLoader implements ILateMixinLoader { public static final Map modMixinsConfig = new ImmutableMap.Builder() .put(Mods.Names.BETTER_QUESTING, true) .put(Mods.Names.MODULAR_UI, true) + .put(Mods.Names.JOURNEY_MAP, true) .build(); @Override diff --git a/src/main/java/com/github/gtexpert/blpc/mixins/journeymap/WaypointStoreMixin.java b/src/main/java/com/github/gtexpert/blpc/mixins/journeymap/WaypointStoreMixin.java new file mode 100644 index 0000000..06c7ae3 --- /dev/null +++ b/src/main/java/com/github/gtexpert/blpc/mixins/journeymap/WaypointStoreMixin.java @@ -0,0 +1,36 @@ +package com.github.gtexpert.blpc.mixins.journeymap; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.github.gtexpert.blpc.integration.jmap.JMapWaypointOutgoing; + +import journeymap.client.model.Waypoint; +import journeymap.client.waypoint.WaypointStore; + +/** + * Detects local waypoint changes made through JourneyMap's own UI ({@code WaypointEditor}) so + * they can be shared with the local player's party. Only {@code save}/{@code remove} are + * hooked — {@code add} is used for JourneyMap's own startup load of previously-saved waypoints + * and would falsely trigger a re-share of every waypoint on every login if hooked too. + *

+ * {@code WaypointEditor.save()} always calls {@code WaypointStore.remove(original)} followed by + * {@code WaypointStore.save(edited)} — even for a brand-new waypoint (editing is modeled as + * "delete old, add new" in JourneyMap). {@link JMapWaypointOutgoing} debounces this so an edit + * produces one shared update instead of a spurious remove+add pair. + */ +@Mixin(value = WaypointStore.class, remap = false) +public class WaypointStoreMixin { + + @Inject(method = "save", at = @At("RETURN")) + private void blpc$onSave(Waypoint waypoint, CallbackInfo ci) { + JMapWaypointOutgoing.onLocalSave(waypoint); + } + + @Inject(method = "remove", at = @At("HEAD")) + private void blpc$onRemove(Waypoint waypoint, CallbackInfo ci) { + JMapWaypointOutgoing.onLocalRemove(waypoint); + } +} diff --git a/src/main/resources/assets/blpc/lang/en_us.lang b/src/main/resources/assets/blpc/lang/en_us.lang index 2774ea6..2cc2a29 100644 --- a/src/main/resources/assets/blpc/lang/en_us.lang +++ b/src/main/resources/assets/blpc/lang/en_us.lang @@ -4,6 +4,7 @@ config.blpc.party=Party config.blpc.server_party=Server Party config.blpc.data=Data config.blpc.protection=Protection +config.blpc.fair_play=Fair Play # Key bindings key.blpc.open_map=Open Chunk Map @@ -196,7 +197,13 @@ blpc.addons.journeymap.title=JourneyMap blpc.addons.journeymap.overlays_on=Claim Overlays: ON blpc.addons.journeymap.overlays_off=Claim Overlays: OFF blpc.addons.journeymap.overlays_tooltip=Show BLPC claim regions on JourneyMap -blpc.addons.journeymap.waypoints_soon=Team Waypoint Sharing (coming soon) +blpc.addons.journeymap.waypoints_on=Team Waypoint Sharing: ON +blpc.addons.journeymap.waypoints_off=Team Waypoint Sharing: OFF +blpc.addons.journeymap.waypoints_tooltip=Show your party owner's shared waypoints on JourneyMap. Only the party owner can add, edit, or remove them. +blpc.addons.journeymap.force_loaded_suffix= (Force Loaded) + +# HUD +blpc.hud.protected_area=Protected: %s # Transit Notifications blpc.transit.member.enter=%s returned home @@ -222,3 +229,4 @@ blpc.toast.join_failed=Could not join the party # Claim Failed Notifications blpc.toast.claim_limit=Claim limit reached (%d/%d) blpc.toast.forceload_limit=Force load limit reached (%d/%d) +blpc.toast.no_party=You must be in a party to claim chunks diff --git a/src/main/resources/assets/blpc/lang/ja_jp.lang b/src/main/resources/assets/blpc/lang/ja_jp.lang index 94c74fd..4b122ed 100644 --- a/src/main/resources/assets/blpc/lang/ja_jp.lang +++ b/src/main/resources/assets/blpc/lang/ja_jp.lang @@ -4,6 +4,7 @@ config.blpc.party=パーティ config.blpc.server_party=サーバーパーティ config.blpc.data=データ config.blpc.protection=保護 +config.blpc.fair_play=フェアプレイ # Key bindings key.blpc.open_map=チャンクマップを開く @@ -23,13 +24,13 @@ command.blpc.disband.success=パーティ「%s」を解散しました。 command.blpc.me.no_party=あなたはパーティに所属していません。 command.blpc.me.your_role=あなたのロール: %s command.blpc.here.wilderness=チャンク (%d, %d) は未確保です。 -command.blpc.here.claimed=チャンク (%d, %d) はパーティ「%s」が確保中(オーナー: %s)。 -command.blpc.here.claimed_force=チャンク (%d, %d) はパーティ「%s」が確保&常時読込中(オーナー: %s)。 -command.blpc.claims.personal=あなたの確保数: %d(常時読込: %d)。パーティに未所属です。 +command.blpc.here.claimed=チャンク (%d, %d) はパーティ「%s」が確保中(オーナー: %s)。 +command.blpc.here.claimed_force=チャンク (%d, %d) はパーティ「%s」が確保&常時読込中(オーナー: %s)。 +command.blpc.claims.personal=あなたの確保数: %d(常時読込: %d)。パーティに未所属です。 command.blpc.claims.party_header=== パーティ「%s」の確保状況 == command.blpc.claims.party_total=確保チャンク数: %d / %d command.blpc.claims.party_force=常時読込数: %d / %d -command.blpc.claims.your_share=あなたの貢献: 確保 %d(常時読込 %d) +command.blpc.claims.your_share=あなたの貢献: 確保 %d(常時読込 %d) command.blpc.invites.empty=届いている招待はありません。 command.blpc.invites.header=招待一覧 (%d 件): command.blpc.invites.hint=/blpc accept または /blpc decline で操作してください。 @@ -196,7 +197,13 @@ blpc.addons.journeymap.title=JourneyMap blpc.addons.journeymap.overlays_on=クレーム表示: ON blpc.addons.journeymap.overlays_off=クレーム表示: OFF blpc.addons.journeymap.overlays_tooltip=JourneyMap上にBLPCのクレーム範囲を表示 -blpc.addons.journeymap.waypoints_soon=チームウェイポイント共有(準備中) +blpc.addons.journeymap.waypoints_on=チームウェイポイント共有: ON +blpc.addons.journeymap.waypoints_off=チームウェイポイント共有: OFF +blpc.addons.journeymap.waypoints_tooltip=パーティオーナーが共有したウェイポイントをJourneyMap上に表示します。追加・編集・削除はオーナーのみ可能です。 +blpc.addons.journeymap.force_loaded_suffix= (強制ロード中) + +# HUD +blpc.hud.protected_area=保護区域: %s # 領域出入り通知 blpc.transit.member.enter=%sが帰宅した @@ -222,3 +229,4 @@ blpc.toast.join_failed=パーティに参加できませんでした # クレーム失敗通知 blpc.toast.claim_limit=確保上限に達しました (%d/%d) blpc.toast.forceload_limit=常時読込上限に達しました (%d/%d) +blpc.toast.no_party=チャンクを確保するにはパーティに所属する必要があります diff --git a/src/main/resources/mixins.blpc.journeymap.json b/src/main/resources/mixins.blpc.journeymap.json new file mode 100644 index 0000000..2123375 --- /dev/null +++ b/src/main/resources/mixins.blpc.journeymap.json @@ -0,0 +1,12 @@ +{ + "package": "com.github.gtexpert.blpc.mixins.journeymap", + "refmap": "mixins.blpc.refmap.json", + "target": "@env(DEFAULT)", + "minVersion": "0.8", + "compatibilityLevel": "JAVA_8", + "mixins": [], + "server": [], + "client": [ + "WaypointStoreMixin" + ] +}