diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e352f3e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,34 @@ +# Normalise line endings — auto-detect text, keep LF +* text=auto eol=lf + +# Windows batch scripts MUST be CRLF or cmd.exe will misinterpret them +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Unix shell scripts stay LF even on Windows checkouts +*.sh text eol=lf +gradlew text eol=lf + +# Binary formats — never diff, never touch +*.jar binary +*.class binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ogg binary +*.wav binary +*.mp3 binary +*.zip binary +*.tar.gz binary +*.gz binary + +# Java sources +*.java text eol=lf diff=java +*.gradle text eol=lf +*.properties text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d469bec2..c98728a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,29 +1,69 @@ -name: build -on: [ push, pull_request ] +name: Build + +on: + push: + branches: ['**'] + tags: ['*'] + pull_request: + workflow_dispatch: {} + +permissions: + contents: write # required to attach artifacts to the Release on tag push + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true jobs: build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + steps: - - name: checkout repository + - name: Checkout uses: actions/checkout@v4 - - name: validate gradle wrapper + + - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 - - name: setup jdk 25 + + - name: Set up JDK 25 uses: actions/setup-java@v4 with: - distribution: 'zulu' - java-version: 25 - - name: make gradle wrapper executable - if: ${{ runner.os != 'Windows' }} + distribution: temurin + java-version: '25' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', 'gradle.properties', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make Gradle wrapper executable + if: runner.os != 'Windows' run: chmod +x ./gradlew - - name: build - run: ./gradlew build - - name: capture build artifacts - if: ${{ runner.os == 'Linux' }} + + - name: Build + run: ./gradlew clean build --no-daemon --stacktrace + + - name: Upload build artifact + if: runner.os == 'Linux' uses: actions/upload-artifact@v4 with: - path: build/libs/ + name: factions-${{ github.sha }} + path: build/libs/*.jar + if-no-files-found: error + + - name: Attach to GitHub Release + if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: build/libs/*.jar + fail_on_unmatched_files: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 17e8dc80..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: publish - -on: - push: - tags: - - "v*.*.*" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: checkout repository - uses: actions/checkout@v4 - - name: validate gradle wrapper - uses: gradle/actions/wrapper-validation@v4 - - name: setup jdk 25 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 25 - - name: make gradle wrapper executable - run: chmod +x ./gradlew - - name: build and generate javadoc - run: | - ./gradlew build - ./gradlew javadoc - - name: Create hash - uses: BlueZeeKing/fabric-mod-hash@v1.2 - - name: Release - uses: softprops/action-gh-release@v2 - with: - files: | - ./build/libs/factions-mc!(-sources).jar - ./*.sha512 - draft: true - - name: Deploy javadoc - uses: crazy-max/ghaction-github-pages@v3 - with: - target_branch: gh-pages - build_dir: build/docs/javadoc - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4f85d6b8..ca33fc56 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ bin/ run/ remappedSrc/ + +# session/build artifacts + +.omo/ +logs/ +xaero/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..97699448 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,415 @@ +# Changelog + +All notable changes to this mod are documented here. + +Format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +--- + +## [3.4] — 2026-08-20 + +**Minecraft 26.2 · Fabric Loader 0.19.3 · Fabric API 0.155.2+26.2** + +Patch release adding a public permission API for third-party mod integration, +Carry On mod support, and fixing one gameplay-affecting enderchest interaction +bug. **Fully save-compatible with 3.3** — no world data migration, no config +migration. **Requires Fabric Loader 0.19.3 or later** (bumped from 0.18.4). + +### Added + +- **`io.icker.factions.api.ClaimPermissions` — public permission API.** A stable, + additive surface for other server-side mods to consult claim rules before + mutating the world. The full decision engine — bypass level, claim ownership, + power sufficiency, guest permissions, guest-grant quotas, ally overrides, and + relationship permissions — is exposed as static methods and used internally by + `InteractionManager` for every break/place/use event. + + ```java + ClaimPermissions.canPlaceBlock(ServerPlayer, BlockPos) // dry-run + ClaimPermissions.canPlaceBlock(ServerPlayer, BlockPos, ItemStack) // dry-run, honors restrictedItems + ClaimPermissions.tryPlaceBlock(ServerPlayer, BlockPos) // allow + consume guest quota + ClaimPermissions.tryPlaceBlock(ServerPlayer, BlockPos, ItemStack) // allow + consume, honors restrictedItems + ClaimPermissions.check(Player, BlockPos, Level, Permissions, + boolean consumeQuota, ItemStack usedItem) // general predicate + ClaimPermissions.isRestrictedForGuests(ItemStack) + ClaimPermissions.isNonMemberInClaim(Player, BlockPos, Level) + ``` + + Consuming variants (`tryPlaceBlock`, `check(..., true, ...)`) decrement guest + quotas exactly once and warn the player via `InteractionsUtil`. Dry-run + variants never mutate state. `ItemStack.EMPTY` skips the guest-restricted-item + check. + +- **Carry On mod integration.** With Carry On 2.11.1+ installed, block pickup + and placement now respect Factions claim rules — non-members cannot carry + blocks out of a claim they lack `BREAK_BLOCKS` on, and cannot place carried + blocks into a claim they lack `PLACE_BLOCKS` on. Guest grants are consumed + exactly once per successful action. Carry On calls `ClaimPermissions` via + optional reflection, so it also works standalone when Factions is absent. + + **Version pairing required.** Carry On 2.11.1+ fails **closed** (denies every + carried-block placement) if it detects a Factions build older than 3.4 — the + `ClaimPermissions` class is missing, so the reflective lookup returns nothing + and the compat layer conservatively rejects. Keep both jars in lockstep on + servers that use both mods. + +### Changed + +- **Minimum Fabric Loader raised to 0.19.3** (from 0.18.4). Required for Carry + On compatibility on Minecraft 26.2. No other API surfaces changed. +- **`InteractionManager` permission logic extracted into `ClaimPermissions`.** + Internal refactor — every break/place/use decision that lived inline in + `InteractionManager.checkPermissions` now delegates to + `ClaimPermissions.check`. Same inputs produce the same `InteractionResult`; + no gameplay change. + +### Fixed + +- **Right-clicking an enderchest without being in a faction was blocked with + "Cannot use enderchests when not in a faction" instead of opening the personal + enderchest.** When `safe.enderChest=true` (faction safe replaces the vanilla + enderchest for members), `FactionsManager.openSafe` unconditionally rejected + non-faction players — sending the `factions.events.no_enderchests_without_faction` + fail message and returning `InteractionResult.FAIL`, which the + `EnderChestBlockMixin` propagated to cancel the vanilla open. Non-faction + players now fall through with `InteractionResult.PASS` so vanilla + `EnderChestBlock.useWithoutItem` opens their personal enderchest. Faction + members continue to open the shared faction safe when the feature is enabled. + When `safe.enderChest=false` the mixin still short-circuits early, so this + change has no effect there — it only matters when the feature is turned on. + + The `factions.events.no_enderchests_without_faction` translation key is now + unreferenced by code but left in `en_us.json` and `ru_ru.json` for downstream + language packs — **no action required**. + +### Upgrade from 3.3 + +1. Stop the server +2. **Verify your Fabric Loader is 0.19.3 or later.** The 3.3 minimum was 0.18.4; + this release bumps that requirement. +3. Replace `factions-mc26.2-3.3.jar` with `factions-mc26.2-3.4.jar` +4. Start the server + +No config edits required. No data migration. Downgrading back to 3.3 is safe. +Third-party mods that reflectively call `ClaimPermissions` will need to handle +its absence when downgraded (Carry On does this automatically). + +### Testing + +`./gradlew build` exits 0. + +--- + +## [3.3] + +**Minecraft 26.2 · Fabric Loader 0.18.4 · Fabric API 0.155.2+26.2** + +Release adding a full two-phase item-trading GUI on `/f trade`, a per-faction elytra flight rule, and removing the superseded `/f settings radar` command. **Fully save-compatible with 3.2** — no world data migration, no config migration. + +### Added + +- **`/f trade` now opens a two-phase trading interface.** On accept, the requesting player + teleports to a configurable facing-west position and the responding player to a + facing-east position (both configurable via the new `tradeStage` config block). Each + player gets a private 27-slot **Placement chest** (Phase 1): place items to trade, then + click the slimeball to confirm. Once both confirm, a read-only **Reveal chest** (Phase 2) + shows each other's offer: click the slimeball to **Accept**, the ender pearl to + **Renegotiate** (returns to Phase 1 with items intact), or magma cream to **Decline**. + When both accept, items swap atomically on the server main thread with a capacity + pre-flight — if either player's inventory is full the trade is rejected and all items + return to their owners. Any disconnect, decline, or GUI-close cancels for both players + and returns items to each owner's inventory (or drops them at the pre-trade position if + inventory full). +- **`/f rules elytra ` disables elytra flight in faction claims.** + LEADER/OWNER only; defaults to `enabled` (no behavior change on existing worlds). When + disabled, any player who enters a claim while fall-flying is force-landed via + `stopFallFlying()` (vanilla fall damage applies). Applies to all visitors including + allies; `user.bypass=true` admins are exempt. + +### Changed + +- **`/f trade` on-accept now opens the trading GUI** instead of teleporting both parties + to the gathering hall. A new `tradeStage` config block (separate from `gather`) + controls the two facing positions. + +### Removed + +- **`/f settings radar`** and the `User.radar` persistent field are removed. The Location + Announcer (added in 3.1) provides the same territory indicator via the actionbar. + Existing user JSON records containing `"Radar": true/false` are silently ignored by the + database layer on load — **no action required**. + +### Configuration + +New `tradeStage` block written to `config/factions.json` on first launch. Existing +settings are preserved. + +```json +{ + "tradeStage": { + "enabled": true, + "requesterX": 1.050, "requesterY": 160.125, "requesterZ": -27.500, + "requesterYaw": 89.7, "requesterPitch": 3.2, + "recipientX": -2.050, "recipientY": 160.125, "recipientZ": -27.482, + "recipientYaw": -90.9, "recipientPitch": 3.2, + "level": "minecraft:overworld" + } +} +``` + +### Upgrade from 3.2 + +1. Stop the server +2. Replace `factions-mc26.2-3.2.jar` with `factions-mc26.2-3.3.jar` +3. Start the server + +No config edits required. No data migration. Downgrading back to 3.2 is safe. + +### Testing + +`./gradlew clean build test` exits 0. All previous tests pass, plus N new suites. + +## [3.2] + +**Minecraft 26.2 · Fabric Loader 0.18.4 · Fabric API 0.155.2+26.2** + +Release adding faction gathering hall travel, faction trade meeting requests, and tighter claim +permissions. **Fully save-compatible with 3.1.1** — no world data migration, no config migration. + +### Added + +- **`/f gather` teleports a player to the faction gathering hall.** Uses the configured hall + coordinates for quick regrouping. +- **`/f trade ` sends a trade meeting request to another faction.** On accept, both + parties teleport to the gathering hall. +- **`/f claim` is now restricted to LEADER/OWNER ranks.** This tightens claim control from the + previous COMMANDER+ access. + +### Changed + +- **`safe.enderChest` now defaults to `false`, restoring personal ender chests by default for new installs.** Previously, the default `true` value redirected ender chest use to the shared faction safe, so every member of a faction opened the same container and players without a faction were blocked from ender chests entirely. The faction safe itself is unchanged and still available through `/f safe`. This only affects fresh installs: existing `config/factions.json` files keep their current `enderChest` value, so servers upgrading this release must change it manually to `"enderChest": false` if they want the new default behavior. Items already stored in a faction safe stay there and remain reachable via `/f safe`; they do not migrate into players' personal ender chests. + +## [3.1.1] + +**Minecraft 26.2 · Fabric Loader 0.18.4 · Fabric API 0.155.2+26.2** + +Patch release covering two tab-completion fixes, one gameplay-affecting economy fix, and a +dead-config cleanup. **Fully save-compatible with 3.1.0** — no world data migration, no config +migration. Existing config files containing `announcer.style` are silently ignored by Gson. + +### Fixed + +- **`/f tp` and `/f kick` tab-completion showed raw UUIDs instead of player names.** When the + server's `ProfileResolver` had no cached entry for a factionmate (common on servers that + disable the Mojang profile cache or restart frequently), the suggestion fallback leaked the + raw UUID string. `/f kick` now prefers the live player's real name for anyone currently + online, keeping the `ProfileResolver → UUID` chain only for offline members it legitimately + needs to target. `/f tp` — which can only ever teleport to online players anyway — now + suggests **only online factionmates**, matching what its executor can actually resolve. No + more UUID suggestions in either command. +- **`/f claim add ` only required power for a single chunk.** The multi-chunk claim path + checked affordability as though claiming exactly one chunk, so `/f claim add 7` acquired up + to 169 chunks (a `13×13` square) for the price of one. Power is now required for **every + genuinely new chunk** in the requested square; chunks already owned by the acting faction + are re-claimed harmlessly and do not add cost. + + **⚠️ Gameplay-affecting change.** Factions that mass-claimed cheaply under the old rule will + find that large claims now cost proportionally more. The `1..7` size cap and the + foreign-claim exclusivity rules are unchanged. **`/f claim add force` still bypasses + the power check for leaders holding `factions.claim.add.force`** — that path is + intentional and unchanged. + +### Removed + +- **`announcer.style` config field and its unused datapack templates.** Nothing in the mod + ever read `AnnouncerConfig.STYLE`; the announcer builds its styling in code via + `AnnouncerManager`. The `data/factions/factions/announcer/*.json` files were reference-only + and shipped for a styling system that was never implemented. Both the field and the + templates are gone. Existing configs containing `"style": "default"` are ignored by Gson — + **no action required**. + +### Upgrade from 3.1.0 + +1. Stop the server +2. Replace `factions-mc26.2-3.1.0.jar` with `factions-mc26.2-3.1.1.jar` +3. Start the server + +No config edits required. No data migration. Downgrading back to 3.1.0 is safe. + +### Testing + +`./gradlew clean build test` exits 0. All 42 tests from 3.1.0 still pass, plus 7 new +arithmetic assertions in the new `ClaimPowerTest` suite (49 total, 9 suites). + +--- + +## [3.1.0] + +**Minecraft 26.2 · Fabric Loader 0.18.4 · Fabric API 0.155.2+26.2** + +Three new features, a performance pass, and fixes for every issue found in a pre-release audit. +Fully backward compatible with 3.0.4 — **no world data migration required**. + +### Added + +#### Location Announcer +Displays the territory name on your actionbar when you cross into a new area, Zelda/HFW style. + +- Shows the **faction name in bold**, coloured with the faction's colour +- Unclaimed land shows a bold white **Wilderness** +- Quick fade-in — the text steps from dark to full colour over ~0.3s, then holds +- Crossing into a new area **always** announces immediately; the previous announcement is cancelled so it fades out cleanly +- Re-sent once per second for the display window, so it doesn't flicker +- Announces only on genuine area changes — staying inside one territory won't repeat +- Works independently of `/f radar` and `/f autoclaim` + +Actionbar only. No resource pack, custom font, sounds, bossbar, or title overlay. + +#### Guest Break/Place Quotas +Faction leaders can grant a limited number of block breaks and/or places to non-members. + +| Command | Description | +|---|---| +| `/f grant break ` | Grant `n` block breaks | +| `/f grant place ` | Grant `n` block places | +| `/f grant revoke ` | Revoke a player's grant | +| `/f grant list` | List all grants for your faction | + +- Requires **LEADER** or **OWNER** rank +- Quota is consumed **only** when the action would otherwise be denied — if your faction's `guest_permissions` already allow it, no quota is spent +- One action costs exactly one quota +- A grant is removed automatically once both counters reach zero +- Quotas persist across restarts +- Cannot be granted to a member of your own faction +- Capped by `maxBreak` / `maxPlace` + +**Restricted items.** Non-members can never use fire/grief items inside a claim, *regardless of quota or `guest_permissions`*. Default block-list: + +``` +minecraft:flint_and_steel +minecraft:fire_charge +minecraft:lava_bucket +``` + +Fully configurable — add `minecraft:tnt` or anything else, or set it to `[]` to disable. Blocked attempts consume **no** quota. **Faction members of every rank, including `GUEST`, are unaffected.** + +#### Faction Teleport +Teleport to a factionmate, with consent. + +| Command | Run by | Effect | +|---|---|---| +| `/f tp ` | requester | Sends a teleport request | +| `/f tp accept` | target | Requester teleports to you | +| `/f tp deny` | target | Rejects the request | + +- The request message is **clickable** — the target can click to accept +- Tab-completion only suggests your own factionmates +- Available to **all ranks**, including `GUEST` +- Requests expire after 60s (configurable) +- Cross-dimension teleporting supported +- All conditions are re-validated at accept time, not request time: both players still online, still in the same faction, request unexpired, requester off cooldown and out of combat +- 15s cooldown and combat block mirror `/f home` + +### Changed + +- **Claim exclusivity is now strict.** A faction can no longer claim a chunk owned by another faction under any circumstances. Admins with `user.bypass` retain the override. +- **Guest grant persistence is now write-throttled.** Previously every quota-consuming block break performed a full synchronous disk write on the main server thread. Writes are now batched (max once per 30s, plus on player disconnect and world save), removing blocking I/O from the block-interaction path. + +### Performance + +- Removed blocking disk I/O from the guest-quota block-interaction path (~40× fewer writes for a 64-block grant) +- Announcer tick loop no longer allocates a `HashMap` every tick — it now early-returns when idle, eliminating 20 allocations/sec of constant GC pressure +- Movement handler resolves a chunk's owning faction once instead of up to three times +- Explosion handlers read the source entity once per block instead of repeatedly +- `User.get()` reduced from two map probes to one + +### Fixed + +- **Announcer stopped announcing after repeated crossings.** A per-area cooldown suppressed legitimate re-entry (out → in → out within 10s would silently skip the third announcement). The cooldown was redundant with edge detection and has been removed entirely. +- **Announcer text flickered bold → unbold.** Wilderness faded in bold but rendered its final frame unbold. All announcements are now consistently bold. +- **Bucket use charged guests 2–3× quota.** `onUseBucket` evaluated placement permission up to three times per action, each consuming quota. One bucket placement now costs exactly one quota. Authorisation outcomes are unchanged — only the timing of the deduction. +- **Guest grants leaked on faction disband.** Disbanding a faction left its grants orphaned in memory *and* rewrote them to disk indefinitely. `Faction.remove()` now cleans them up. +- **Exhausted grants could resurrect after a crash.** Grant removal at zero quota now force-persists instead of using the write throttle. +- **Announcer state leaked per player.** Player-keyed announcer maps were never cleared on disconnect. All state is now dropped on logout. +- **Teleport denial message was wrong for the requester.** Both parties received *"Teleport request denied"*; the requester now correctly sees *"X denied your teleport request"*. + +### Configuration + +Three new blocks are written to `config/factions.json` on first launch. **Existing settings are preserved.** + +```json +{ + "announcer": { + "enabled": true, + "displaySeconds": 5, + "style": "default" + }, + "guestGrant": { + "maxBreak": 64, + "maxPlace": 64, + "restrictedItems": [ + "minecraft:flint_and_steel", + "minecraft:fire_charge", + "minecraft:lava_bucket" + ] + }, + "teleport": { + "enabled": true, + "cooldownSeconds": 15, + "requestExpirySeconds": 60, + "damageCooldown": 100 + } +} +``` + +**Permissions:** `factions.tp` (default level 0 — available to all faction members). + +### Upgrade from 3.0.4 + +1. Stop the server +2. **Back up your world** (specifically the `factions/` data directory) +3. Replace `factions-mc26.2-3.0.4.jar` with `factions-mc26.2-3.1.0.jar` +4. Start the server + +No config edits required. No data migration. Downgrading back to 3.0.4 is safe — the new `guestgrant` data file is simply ignored, and unknown config keys are skipped. + +### Known issues and limitations + +Documented deliberately. None are regressions from 3.0.4 unless noted. + +- **`announcer.style` is not implemented.** ~~The config field exists and the datapack templates under `data/factions/factions/announcer/` ship with the mod, but nothing reads them — announcement styling is currently built in code. The field is inert; changing it has no effect. Slated for removal or implementation in 3.1.x.~~ **Fixed in 3.1.1** — the field and templates have been removed. +- **Multi-chunk claims only charge power for one chunk.** ~~`/f claim add ` validates power as though claiming a single chunk, so `/f claim add 7` acquires up to 169 chunks for the cost of one. **Pre-existing since 3.0.4** — carried forward unchanged, not introduced here. Worth addressing in a future release if land economy matters on your server.~~ **Fixed in 3.1.1** — power is now required for every genuinely new chunk in the requested square. +- **Overclaimed factions lose all protection.** When a faction's claim count exceeds its power, every interaction — including chest access — is permitted. Pre-existing, believed intentional. +- **Teleport can be used as combat reinforcement.** `/f tp accept` checks combat status on the requester but not the target, so a player under attack can summon a teammate. An inherent consequence of the consent-based design. +- **Teleport requests can be stomped.** Only one pending request is tracked per target, so a same-faction griefer can repeatedly overwrite a teammate's legitimate request. +- **Guest grants only resolve online players.** `/f guest grant` and `/f tp` look up targets by name among online players; offline players cannot be targeted. +- **Explosions consume quota per block.** A guest igniting TNT can spend their entire break quota in one blast. Considered correct behaviour (each destroyed block is one break) but worth knowing. +- **`/f xaerosync` is ungated.** No permission requirement or cooldown; rebuilds the full claim payload per invocation. Pre-existing. +- **Admin GUI permission split.** Opening the admin GUI requires `factions.admin.gui`, but its spoof/bypass buttons don't re-check `factions.admin.spoof` / `factions.admin.bypass`. Only relevant if those permissions are configured separately. Pre-existing. + +### Testing + +42 automated tests across 8 suites, all passing: + +| Suite | Tests | +|---|---| +| `TeleportRequestManagerTest` | 15 | +| `AnnouncerManagerTest` | 6 | +| `GuestGrantTest` | 6 | +| `RestrictedItemsTest` | 6 | +| `ClaimExclusivityTest` | 4 | +| `FactionClaimCountsTest` | 3 | +| `ClaimSyncPayloadTest` | 1 | +| `WorldUtilsTest` | 1 | + +`./gradlew clean build test` exits 0. + +### Compatibility + +No changes to `io.icker.factions.api.*` event signatures. No new mixins. No persisted schema changes to existing entities — `GuestGrant` is a new, additive store. Dynmap, BlueMap, squaremap, PlaceholderAPI, LuckPerms, StyledChat, and sgui integrations are untouched. + +--- + +## [3.0.4] and earlier + +See the git history prior to this changelog. diff --git a/LICENSE b/LICENSE index f1f39f44..66fe7980 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2023 ickerio +Copyright (c) 2026 Josh Elland (joshes14) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 927ce912..1b12cf59 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,440 @@ -
+

Factions

-Factions Mod Icon +

+ Server-side factions for Minecraft Fabric 26.2.
+ Ultralight, dependency-free, event-driven. Claims, power economy, GUI trading, guest quotas, gathering hall, permission API, and a public ClaimPermissions surface for third-party mod integration. +

-# Factions Mod +

+ Release + Build + Minecraft + Fabric + Java + License +

-Highly customizable, lightweight and elegant factions mod for the [Fabric loader][fabric] in Minecraft 1.19 +--- -[![Release](https://img.shields.io/github/v/release/ickerio/factions?style=for-the-badge&include_prereleases&sort=semver)][github:releases] -[![Available For](https://img.shields.io/badge/dynamic/json?label=Available%20For&style=for-the-badge&color=e64626&query=version&url=https%3A%2F%2Fapi.blueish.dev%2Fapi%2Fminecraft%2Fversion%3Fid%3Dfactions)][modrinth] - [![Downloads](https://img.shields.io/badge/dynamic/json?label=Downloads&style=for-the-badge&color=e64626&query=downloads&url=https%3A%2F%2Fapi.blueish.dev%2Fapi%2Fminecraft%2Fdownloads%3Fcurseid%3D497362%26modrinthid%3Dfactions)][modrinth:releases] +## Table of Contents -
+- [At a glance](#at-a-glance) +- [Feature matrix](#feature-matrix) +- [Compatibility](#compatibility) +- [Install](#install) +- [Commands reference](#commands-reference) +- [Configuration reference](#configuration-reference) +- [Public API](#public-api) +- [Architecture](#architecture) +- [Security trade-offs and limitations](#security-trade-offs-and-limitations) +- [Build from source](#build-from-source) +- [Troubleshooting](#troubleshooting) +- [Changelog](#changelog) +- [Credits and upstream](#credits-and-upstream) +- [License](#license) -### **ABOUT** +--- -Factions Mod is an ultra lightweight, fast, and elegant solution to factions in modern minecraft. The **server-side** mod expands upon all the classic factions features whilst also focusing on customization and performance. Grow your faction, expand your claims, and storm your enemies for their chunks and loot. +## At a glance -A faction's power cap increases as new members join, expanding their ability to claim more land. For each claim they make, it requires that faction to sustain more power. Dying to other players will temporarily lose faction power and if it drops below the required threshold, all their claims will be vulnerable to being overtaken. +Factions is a **server-side-only** Fabric mod. Nothing to install on the client — one jar in the server's `mods/` directory adds 25 commands, 12 configurable subsystems, sgui-based inventory UIs (admin panel, info screens, two-phase trade GUI), and a stable Java API for other server mods to consult claim rules before they mutate the world. Persistence is a small JSON store under `/factions/` (no external database). Optional integrations with Dynmap, BlueMap, squaremap, PlaceholderAPI, LuckPerms, and StyledChat are auto-detected at load time — none are required. -  +Power economy holds the whole design together. A faction can claim `claimCount * CLAIM_WEIGHT ≤ power` chunks; die too much and your claims lose protection until you regain power. That single invariant is enforced by [`ClaimPermissions.check`](src/main/java/io/icker/factions/api/ClaimPermissions.java:64) for every break, place, and use event. -### **FEATURES** +## Feature matrix -- 🎯 Fully featured factions mod with over 30 [commands][wiki:commands] -- ✨ Faction ranks, colors, MOTD and descriptions -- 🎉 In faction private chat, global chat and a stylized player list -- ⚡ Extreme performance and reliability -- ⚙️ Advanced [configuration][wiki:config] and customization options -- 🔥 Dynmap and Lucko Perms support out the box -- 🚀 Event driven API for further extensibility -- 💬 Strong [community][discord] and active developer support +Every feature entry links directly to the code that implements it. -  +| Area | Feature | Config toggle | Implementation | +|---|---|---|---| +| Factions | Create / disband / invite / join / leave | `maxFactionSize` | [`CreateCommand`](src/main/java/io/icker/factions/command/CreateCommand.java), [`DisbandCommand`](src/main/java/io/icker/factions/command/DisbandCommand.java), [`InviteCommand`](src/main/java/io/icker/factions/command/InviteCommand.java), [`JoinCommand`](src/main/java/io/icker/factions/command/JoinCommand.java), [`LeaveCommand`](src/main/java/io/icker/factions/command/LeaveCommand.java) | +| Factions | Rank hierarchy (OWNER > LEADER > COMMANDER > MEMBER > GUEST) | — | [`User.Rank`](src/main/java/io/icker/factions/api/persistents/User.java:29), [`RankCommand`](src/main/java/io/icker/factions/command/RankCommand.java) | +| Factions | Kick / member listing | — | [`KickCommand`](src/main/java/io/icker/factions/command/KickCommand.java), [`MemberCommand`](src/main/java/io/icker/factions/command/MemberCommand.java) | +| Claims | Claim / autoclaim / multi-chunk claim | `power.*`, `claimProtections` | [`ClaimCommand`](src/main/java/io/icker/factions/command/ClaimCommand.java), [`Claim`](src/main/java/io/icker/factions/api/persistents/Claim.java) | +| Claims | Strict claim exclusivity (no overtake) | — | [`ClaimCommand`](src/main/java/io/icker/factions/command/ClaimCommand.java) | +| Claims | Access-level enforcement per chunk | — | [`InteractionManager`](src/main/java/io/icker/factions/core/InteractionManager.java), [`ClaimPermissions`](src/main/java/io/icker/factions/api/ClaimPermissions.java) | +| Claims | ASCII map | — | [`MapCommand`](src/main/java/io/icker/factions/command/MapCommand.java) | +| Movement | `/f home` (per-faction home + cooldown + combat block) | `home.*` | [`HomeCommand`](src/main/java/io/icker/factions/command/HomeCommand.java), [`Home`](src/main/java/io/icker/factions/api/persistents/Home.java) | +| Movement | `/f tp ` with consent | `teleport.*` | [`TeleportCommand`](src/main/java/io/icker/factions/command/TeleportCommand.java), [`TeleportRequestManager`](src/main/java/io/icker/factions/core/TeleportRequestManager.java) | +| Movement | `/f gather` — teleport to gathering hall | `gather.*` | [`GatherCommand`](src/main/java/io/icker/factions/command/GatherCommand.java) | +| Movement | `/f trade ` — two-phase item trading GUI | `tradeStage.*` | [`TradeCommand`](src/main/java/io/icker/factions/command/TradeCommand.java), [`TradeSession`](src/main/java/io/icker/factions/core/TradeSession.java), [`TradePlacementGui`](src/main/java/io/icker/factions/ui/TradePlacementGui.java), [`TradeRevealGui`](src/main/java/io/icker/factions/ui/TradeRevealGui.java) | +| Diplomacy | Ally / neutral / enemy relationships + permission grants | `relationships.*` | [`DeclareCommand`](src/main/java/io/icker/factions/command/DeclareCommand.java), [`PermissionCommand`](src/main/java/io/icker/factions/command/PermissionCommand.java), [`Relationship`](src/main/java/io/icker/factions/api/persistents/Relationship.java) | +| Guests | Break / place quotas for non-members | `guestGrant.*` | [`GrantCommand`](src/main/java/io/icker/factions/command/GrantCommand.java), [`GuestGrant`](src/main/java/io/icker/factions/api/persistents/GuestGrant.java) | +| Guests | Restricted-item blocklist (fire, lava, etc.) | `guestGrant.restrictedItems` | [`ClaimPermissions.isRestrictedForGuests`](src/main/java/io/icker/factions/api/ClaimPermissions.java:146) | +| Storage | Shared faction safe (double chest) | `safe.*` | [`SafeCommand`](src/main/java/io/icker/factions/command/SafeCommand.java), [`EnderChestBlockMixin`](src/main/java/io/icker/factions/mixin/EnderChestBlockMixin.java) | +| Chat | Focus / faction / global chat modes + tab menu | `display.*` | [`ChatManager`](src/main/java/io/icker/factions/core/ChatManager.java), [`SettingsCommand`](src/main/java/io/icker/factions/command/SettingsCommand.java) | +| UX | Location Announcer (actionbar territory name) | `announcer.*` | [`AnnouncerManager`](src/main/java/io/icker/factions/core/AnnouncerManager.java) | +| Rules | Per-faction gameplay rules (e.g. elytra flight) | `/f rules` | [`RulesCommand`](src/main/java/io/icker/factions/command/RulesCommand.java) | +| Admin | Admin GUI, bypass, spoof-user | `requiredBypassLevel` | [`AdminCommand`](src/main/java/io/icker/factions/command/AdminCommand.java), [`AdminGui`](src/main/java/io/icker/factions/ui/AdminGui.java) | +| Integrations | Dynmap / BlueMap / squaremap claim overlays | auto-detected | [`DynmapWrapper`](src/main/java/io/icker/factions/util/DynmapWrapper.java), [`BlueMapWrapper`](src/main/java/io/icker/factions/util/BlueMapWrapper.java), [`SquareMapWrapper`](src/main/java/io/icker/factions/util/SquareMapWrapper.java) | +| Integrations | Xaero's Minimap claim sync | `/f xaerosync` | [`XaeroSyncCommand`](src/main/java/io/icker/factions/command/XaeroSyncCommand.java), [`ClaimSyncSender`](src/main/java/io/icker/factions/core/ClaimSyncSender.java) | +| Integrations | PlaceholderAPI expansion | auto-detected | [`PlaceholdersWrapper`](src/main/java/io/icker/factions/util/PlaceholdersWrapper.java) | +| Integrations | LuckPerms fabric-permissions-api hooks | — | permission checks throughout | +| Integrations | StyledChat compat | auto-detected | [`StyledChatCompatibility`](src/main/java/io/icker/factions/util/StyledChatCompatibility.java) | +| API (3.4+) | `ClaimPermissions` — public permission surface | — | [`ClaimPermissions`](src/main/java/io/icker/factions/api/ClaimPermissions.java) | +| API | Event bus — faction, claim, player, relationship, misc | — | [`FactionEvents`](src/main/java/io/icker/factions/api/events/FactionEvents.java), [`ClaimEvents`](src/main/java/io/icker/factions/api/events/ClaimEvents.java), [`PlayerEvents`](src/main/java/io/icker/factions/api/events/PlayerEvents.java), [`RelationshipEvents`](src/main/java/io/icker/factions/api/events/RelationshipEvents.java), [`MiscEvents`](src/main/java/io/icker/factions/api/events/MiscEvents.java) | -### **GET STARTED** +## Compatibility -Factions Mod is very intuitive and works immediately after installation, requiring no additional configuration. However, you can read further about the mod on the [Wiki][wiki]. Our wiki goes in depth about the factions mechanics, its configuration, commands and integrations. +| Dependency | Version | Interaction | +|---|---|---| +| Minecraft | `~26.2` | required | +| Fabric Loader | `>=0.19.3` | required (raised in 3.4 for Carry On compat) | +| Fabric API | `0.155.2+26.2` | required | +| Java | `>=25` | required | +| fabric-permissions-api-v0 | any | suggested (LuckPerms hooks) | +| Dynmap | `>=3.0` | optional, auto-detected | +| BlueMap | `>=2.0` | optional, auto-detected | +| squaremap | `>=1.0` | optional, auto-detected | +| PlaceholderAPI | `3.1.0-beta.1+26.2` | optional, auto-detected | +| StyledChat | any | optional, auto-detected | +| Carry On | `>=2.11.1` | optional, calls `ClaimPermissions` reflectively | -A list of all **commands** is available on our [wiki][wiki:commands] +> [!NOTE] +> This mod is **server-side only**. Clients need Fabric API and Fabric Loader to connect to a Fabric server (as they would for any Fabric mod), but do not need Factions itself installed. -Have an issue or a suggestion? Join [our discord][discord] +## Install -### **License** -[MIT](LICENSE) +### Server (production) -[fabric]: https://fabricmc.net/ -[modrinth]: https://modrinth.com/mod/factions -[modrinth:releases]: https://modrinth.com/mod/factions/versions -[github:releases]: https://github.com/ickerio/factions/releases -[wiki]: https://github.com/ickerio/factions/wiki -[wiki:config]: https://github.com/ickerio/factions/wiki/Config -[wiki:commands]: https://github.com/ickerio/factions/wiki/Commands -[discord]: https://discord.gg/tHPFegeAY8 +1. Confirm server is Fabric 0.19.3+ on Minecraft 26.2 with Fabric API `0.155.2+26.2` installed. +2. Drop `factions-mc26.2-3.4.jar` from the [latest release](https://github.com/joshes14/factions/releases/latest) into `mods/`. +3. Start the server. First-line confirmation in `logs/latest.log`: + ``` + [INFO] [Factions] Initialized Factions Mod + ``` +4. A `config/factions.json` is written on first launch. See [Configuration reference](#configuration-reference) for defaults. +5. Player data appears under `/factions/` (JSON files, one per persistent entity type). + +### Client + +No client install required. Vanilla Fabric clients connect fine. + +### Development (singleplayer / test server) + +`./gradlew runServer` — launches an integrated dev server at `run/`. All Factions commands available immediately. Loom config in `build.gradle` splits client/server sourcesets and uses `log4j-dev.xml` for dev-only logging. + +## Commands reference + +Every command is registered under both `/factions ...` and `/f ...`. Twenty-five subcommands. + +| Command | Rank required | Effect | +|---|---|---| +| `/f create ` | any | Create a new faction; caller becomes OWNER | +| `/f disband` | OWNER | Disband the faction, clear all claims and grants | +| `/f invite ` | LEADER+ | Invite a player to your faction | +| `/f join ` | any | Join an open faction or accept an invite | +| `/f leave` | any (not OWNER) | Leave your faction | +| `/f kick ` | LEADER+ | Kick a member (falls back to UUID lookup for offline targets) | +| `/f rank ` | OWNER | Promote/demote a member | +| `/f info [faction]` | any | Show faction stats, members, MOTD | +| `/f list` | any | Paginated list of all factions | +| `/f member` | any | Show your faction's roster | +| `/f map` | any | ASCII map of nearby chunks | +| `/f modify ` | LEADER+ | Edit faction metadata | +| `/f claim [add [size] [force]\|remove\|list\|auto]` | LEADER+ | Claim / unclaim chunks; multi-chunk claims charge power for every genuinely new chunk (fixed in 3.1.1) | +| `/f home [set]` | any / LEADER+ set | Teleport to faction home; combat-locked, cooldown-gated | +| `/f gather` | any | Teleport to the configured gathering hall (3.2) | +| `/f tp ` | any | Consent-based teleport to a factionmate (3.1) | +| `/f trade ` | LEADER+ | Two-phase trading GUI with another faction (3.3) | +| `/f safe` | any | Open the shared faction safe (double chest) | +| `/f declare ` | LEADER+ | Set relationship | +| `/f permission ` | LEADER+ | Grant a permission to another faction | +| `/f rules ` | LEADER+ | Toggle per-faction rules (e.g. `elytra`, `mobGriefing`) | +| `/f grant ` | LEADER+ | Grant a guest break/place quota (3.1) | +| `/f grant revoke ` | LEADER+ | Revoke a grant | +| `/f grant list` | LEADER+ | List active grants | +| `/f settings` | any | Personal settings (chat mode, sound mode, autoclaim) | +| `/f admin` | op / `factions.admin.gui` | Open the admin GUI | +| `/f xaerosync` | any | Force-resync Xaero's Minimap claim overlay | + +Tab completion uses live player names where possible and falls back to the profile cache for offline targets. `/f tp` and `/f kick` no longer suggest raw UUIDs (fixed in 3.1.1). + +## Configuration reference + +Written to `config/factions.json` on first launch. Missing keys use defaults; extra keys are ignored. Full definition: [`Config.java`](src/main/java/io/icker/factions/config/Config.java). + +**Root** + +| Field | Default | Purpose | +|---|---|---| +| `version` | `3` | Config schema version; mismatch logs a warning but does not abort | +| `gui` | `true` | Enable sgui-based UIs (admin, info, modify, trade) | +| `blockTNT` | `false` | Cancel TNT explosions inside claims regardless of other rules | +| `maxFactionSize` | `-1` | Member cap (`-1` = unlimited) | +| `friendlyFire` | `false` | Allow factionmates to damage each other | +| `requiredBypassLevel` | `2` | OP level required to use `/f admin bypass` | +| `claimProtections` | `true` | Master switch for claim-permission enforcement | +| `language` | `"en_us"` | Server default lang; individual clients override | + +**Power** ([`PowerConfig`](src/main/java/io/icker/factions/config/PowerConfig.java)) + +Governs the claim economy: how much power a member starts with, how death costs, and how much power a claim consumes. + +**Guest grant** ([`GuestGrantConfig`](src/main/java/io/icker/factions/config/GuestGrantConfig.java)) + +| Field | Default | Purpose | +|---|---|---| +| `maxBreak` | `64` | Cap on a single break grant | +| `maxPlace` | `64` | Cap on a single place grant | +| `restrictedItems` | `[flint_and_steel, fire_charge, lava_bucket]` | Items non-members can never use in claims, regardless of grant | + +**Announcer** ([`AnnouncerConfig`](src/main/java/io/icker/factions/config/AnnouncerConfig.java)) + +| Field | Default | Purpose | +|---|---|---| +| `enabled` | `true` | Show faction name on actionbar when crossing territory borders | +| `displaySeconds` | `5` | Hold time for the announcement text | + +The `style` field was removed in 3.1.1 — existing configs containing `"style"` are silently ignored. + +**Safe** ([`SafeConfig`](src/main/java/io/icker/factions/config/SafeConfig.java)) + +| Field | Default | Purpose | +|---|---|---| +| `enderChest` | `false` | When `true`, replaces the vanilla ender chest with the shared faction safe for members. Default changed to `false` in 3.2 | + +> [!WARNING] +> Enabling `safe.enderChest` means every faction member opens the **same** container from any ender chest. As of 3.4, non-faction players fall through to their personal ender chests correctly. Faction members do not. + +**Home** ([`HomeConfig`](src/main/java/io/icker/factions/config/HomeConfig.java)) + +Cooldown + combat-block config for `/f home`. + +**Teleport** ([`TeleportConfig`](src/main/java/io/icker/factions/config/TeleportConfig.java)) + +| Field | Default | Purpose | +|---|---|---| +| `enabled` | `true` | Enable `/f tp` | +| `cooldownSeconds` | `15` | Cooldown between successful teleports (mirrors `/f home`) | +| `requestExpirySeconds` | `60` | Pending request timeout | +| `damageCooldown` | `100` | Combat-block window (ticks) after taking damage | + +**Gather** ([`GatherConfig`](src/main/java/io/icker/factions/config/GatherConfig.java)) + +Coordinates + dimension for the `/f gather` hall. + +**Trade stage** ([`TradeStageConfig`](src/main/java/io/icker/factions/config/TradeStageConfig.java)) + +Coordinates for the two facing positions where `/f trade` deposits both parties. See CHANGELOG 3.3 for the full schema. + +**Display** ([`Config.DisplayConfig`](src/main/java/io/icker/factions/config/Config.java:136)) + +| Field | Default | Purpose | +|---|---|---| +| `factionNameMaxLength` | `-1` | Cap on `/f create` name length (`-1` = unlimited) | +| `changeChat` | `true` | Modify chat format with faction prefix | +| `tabMenu` | `true` | Replace vanilla tab list with grouped faction view | +| `nameBlackList` | `[wilderness, factionless, без фракции]` | Disallowed faction names | +| `powerMessage` | `true` | Send power-change notifications | + +**Relationships** ([`Config.RelationshipConfig`](src/main/java/io/icker/factions/config/Config.java:154)) + +| Field | Default | Purpose | +|---|---|---| +| `allyOverridesPermissions` | `true` | Allies inherit member-level access on member-tier claims | +| `defaultGuestPermissions` | `[USE_BLOCKS, USE_ENTITIES]` | Baseline guest access on newly created factions | + +## Public API + +The `io.icker.factions.api` package is the stable, additive surface for third-party server mods. Depending on it does not require any Factions-side wiring — call it reflectively if you want soft-compat. + +### `ClaimPermissions` (added in 3.4) + +Consult the same decision engine `InteractionManager` uses internally, without duplicating the bypass/ownership/power/quota/ally logic. + +```java +import io.icker.factions.api.ClaimPermissions; +import io.icker.factions.api.persistents.Relationship.Permissions; + +// Dry-run: "will this succeed?" — never mutates state. +ClaimPermissions.canPlaceBlock(serverPlayer, pos); +ClaimPermissions.canPlaceBlock(serverPlayer, pos, itemStack); // honors restrictedItems + +// Attempt: allow AND consume a guest quota if that was the mechanism that permitted it. +ClaimPermissions.tryPlaceBlock(serverPlayer, pos); +ClaimPermissions.tryPlaceBlock(serverPlayer, pos, itemStack); + +// General predicate. +InteractionResult r = ClaimPermissions.check( + player, pos, level, Permissions.BREAK_BLOCKS, + /* consumeQuota */ true, + itemUsed +); + +// Utility checks. +ClaimPermissions.isRestrictedForGuests(itemStack); +ClaimPermissions.isNonMemberInClaim(player, pos, level); +``` + +- **Dry-run** variants never mutate state. +- **Consuming** variants decrement guest quotas exactly once and warn via `InteractionsUtil`. +- `ItemStack.EMPTY` skips the restricted-item check. +- Full source: [`ClaimPermissions.java`](src/main/java/io/icker/factions/api/ClaimPermissions.java). + +### Event bus + +Register listeners via the `EVENT.register(...)` pattern on any of: + +- [`FactionEvents`](src/main/java/io/icker/factions/api/events/FactionEvents.java) — CREATE, DISBAND, MODIFY, POWER_CHANGE, MEMBER_JOIN, MEMBER_LEAVE +- [`ClaimEvents`](src/main/java/io/icker/factions/api/events/ClaimEvents.java) — CLAIM, UNCLAIM +- [`PlayerEvents`](src/main/java/io/icker/factions/api/events/PlayerEvents.java) — CHAT, JOIN, LEAVE, KILL +- [`RelationshipEvents`](src/main/java/io/icker/factions/api/events/RelationshipEvents.java) — DECLARE +- [`MiscEvents`](src/main/java/io/icker/factions/api/events/MiscEvents.java) — server lifecycle + +## Architecture + +``` +Player action (break / place / use / attack / chat / cmd) + │ + ▼ +┌────────────────────────────────────────────────┐ +│ Mixin layer (10 mixins) │ +│ - EnderChestBlockMixin / BaseContainerBEMixin │ +│ - ServerExplosionMixin / CombatTrackerAccessor │ +│ - ServerGamePacketListenerImplMixin (movement) │ +│ - ServerPlayerGameModeMixin (break events) │ +│ - PlayerListMixin (tab menu) │ +└────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────┐ +│ Core managers │ +│ InteractionManager ─ break/place/use routing │ +│ FactionsManager ─ safe container lifecycle │ +│ ChatManager ─ chat modes + tab menu │ +│ AnnouncerManager ─ actionbar territory name │ +│ TeleportRequest* ─ /f tp state machine │ +│ TradeSession ─ /f trade phase FSM │ +│ ServerManager ─ save/tick hooks │ +└────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────┐ +│ API layer │ +│ ClaimPermissions.check() ─ single source of │ +│ truth: bypass? ownership? power sufficient? │ +│ guest permissions? guest grant? ally override? │ +│ relationship permission? │ +└────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────┐ +│ Persistents (JSON store) │ +│ Faction · User · Claim · Home · Relationship │ +│ GuestGrant · FactionClaimCounts │ +│ Database.load / Database.save (throttled) │ +└────────────────────────────────────────────────┘ + │ + ▼ + /factions/*.json + Fabric event bus +``` + +### Why the mixin layer? + +Fabric events cover many of the hooks Factions needs (block break, entity interact, chat, player join) but not all (ender chest replacement, container access from adjacent chunks, explosion attribution). Mixins fill exactly those gaps — every mixin is documented at the top of its file with the reason it exists. + +### Why is `ClaimPermissions` a separate class from `InteractionManager`? + +Before 3.4, the entire permission decision tree lived inline inside `InteractionManager.checkPermissions`. Third-party mods (Carry On being the immediate motivator) needed to reproduce that logic for compatibility. Extracting it into a stable, additive API class means: + +1. Other mods consult the exact same decision engine — no drift. +2. Refactoring the internal check flow does not break external callers as long as `ClaimPermissions.check` preserves its input/output contract. +3. Callers can dry-run (`canPlaceBlock`) or commit-and-consume (`tryPlaceBlock`) — a distinction that matters for guest quotas. + +### Why JSON persistence? + +Factions has always aimed for zero-setup deployment. A SQLite dependency triggers native-lib version pain across mod platforms; a Postgres dependency is absurd for a per-world data store. JSON files under `/factions/` are trivially readable, trivially backupable, and cheap to load at server start. Write throttling ([`GuestGrant.saveThrottled`](src/main/java/io/icker/factions/api/persistents/GuestGrant.java:92)) keeps the block-interaction path allocation-free. + +## Security trade-offs and limitations + +Documented deliberately. None are regressions. + +> [!WARNING] +> **Overclaimed factions lose all protection.** When a faction's `claimCount * CLAIM_WEIGHT` exceeds its current power, every interaction inside its claims — including chest access, sign edits, and entity attacks — passes without a permission check. This is intentional gameplay design (it creates the raid window), but it means an overclaimed faction cannot rely on Factions to guard sensitive containers. + +> [!WARNING] +> **`/f tp accept` checks combat status on the requester, not the target.** A player under attack can summon a factionmate as reinforcement. This is an inherent consequence of the consent-based teleport design. If your server bans combat log or reinforcement, disable `/f tp` via `teleport.enabled = false`. + +> [!WARNING] +> **Guest-quota grants only resolve online players.** `/f grant ` and `/f tp` look up targets by name among currently-online players. Offline targeting requires a UUID and is not exposed via command. + +> [!NOTE] +> **Teleport request stomping.** Only one pending `/f tp` request is tracked per target. A same-faction griefer can repeatedly overwrite a teammate's legitimate request, delaying accepts. Kick the griefer. + +> [!NOTE] +> **Explosions consume guest quota per block.** A guest igniting TNT can spend their entire break quota in one blast. Considered correct (each destroyed block is one break). Raise `guestGrant.maxBreak` if you want more tolerant defaults. + +> [!NOTE] +> **`/f xaerosync` is ungated.** No permission requirement, no cooldown, rebuilds the full claim payload per invocation. Cheap enough on small servers; consider gating via LuckPerms on larger deployments. + +> [!NOTE] +> **Admin GUI permission split.** Opening the admin GUI requires `factions.admin.gui`, but its spoof/bypass buttons do not re-check `factions.admin.spoof` / `factions.admin.bypass`. Only relevant if you configure those permissions separately. + +## Build from source + +```bash +git clone https://github.com/joshes14/factions.git +cd factions +chmod +x ./gradlew +./gradlew clean build +``` + +Output: `build/libs/factions-mc26.2-3.4.jar` (main) and `build/libs/factions-mc26.2-3.4-sources.jar` (sources). + +Tests: `./gradlew test` (JUnit 5, no external dependencies). + +Dev server: `./gradlew runServer`. + +## Troubleshooting + +### Symptom: `Initialized Factions Mod` missing from `logs/latest.log` + +Mod jar not picked up. Check: +- Server is Fabric (not Forge / Paper). +- Loader is `>= 0.19.3` — check `logs/latest.log` for the loader banner. +- File is in `mods/`, not `plugins/` or `datapacks/`. + +### Symptom: `Config file incompatible (requires version 3)` + +Your `config/factions.json` has a lower `version` field than this build expects. Back up the file, delete it, restart the server, then merge your customizations back into the freshly generated config. + +### Symptom: `/f trade` teleports both parties but no GUI appears + +Check `tradeStage.enabled` is `true` and the configured coordinates are reachable in the configured level. If both are set, the sgui integration is present at load time (the mod bundles sgui internally via Gradle `include`, so this should never fail on a supported Fabric version). + +### Symptom: Non-member cannot open their personal ender chest + +Fixed in 3.4. Confirm the running jar is `3.4` (jar filename or `logs/latest.log` version line). Downgrade will restore the bug. + +### Symptom: Guest quota drains without a break happening + +Fixed in 3.1.0 for bucket use (used to charge 2–3× per action). If still observed on 3.1.0+, the block is likely being broken by an explosion — one destroyed block = one break, by design. + +### Symptom: `/f tp` suggestions show raw UUIDs + +Fixed in 3.1.1. Confirm the running jar is `>= 3.1.1`. + +## Changelog + +Full history including pre-release notes: [`CHANGELOG.md`](CHANGELOG.md). + +**3.4** — `ClaimPermissions` public API for third-party mod integration, Carry On mod compat, ender chest fallback fix for non-faction players. Fabric Loader minimum raised to 0.19.3. + +**3.3** — Two-phase trade GUI on `/f trade`, per-faction elytra flight rule, removed `/f settings radar`. + +**3.2** — `/f gather` (gathering hall teleport), `/f trade ` (trade requests), `/f claim` restricted to LEADER+, `safe.enderChest` defaults to `false`. + +**3.1.1** — `/f tp` and `/f kick` tab-completion name-based, multi-chunk claim power fix, dead `announcer.style` removed. + +**3.1.0** — Location Announcer, guest break/place quotas, `/f tp` consent teleport, strict claim exclusivity, performance pass. + +## Credits and upstream + +Downstream fork of [`ickerio/factions`](https://github.com/ickerio/factions) (MIT). Original authors: [ickerio](https://github.com/ickerio), [BlueZeeKing](https://github.com/BlueZeeKing), [PyPylia](https://github.com/PyPylia). This fork is maintained by [joshes14](https://github.com/joshes14) and tuned for the `yayserver2` deployment. + +Both the upstream copyright (2023) and the downstream fork copyright (2026) are preserved in [`LICENSE`](LICENSE). + +## License + +[MIT](LICENSE) — permissive, requires attribution. See the LICENSE file for the full text and copyright chain. diff --git a/build.gradle b/build.gradle index 212e87c6..945294f3 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ repositories { } loom { + splitEnvironmentSourceSets() log4jConfigs.from(file("log4j-dev.xml")) } @@ -49,14 +50,21 @@ dependencies { compileOnly "de.bluecolored.bluemap:BlueMapAPI:${project.bluemap_api_version}" compileOnly "maven.modrinth:styled-chat:${project.styled_chat_version}" compileOnly "xyz.jpenilla:squaremap-api:${project.squaremap_api_version}" + compileOnly "maven.modrinth:NcUtCpym:JN72jos8" + compileOnly "maven.modrinth:1bokaNcj:W7vHFz3T" implementation include("xyz.nucleoid:server-translations-api:${project.stapi_version}") + + testImplementation "org.junit.jupiter:junit-jupiter:5.10.0" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.10.0" } +def modVersion = project.version + processResources { - inputs.property "version", project.version + inputs.property "version", modVersion filesMatching("fabric.mod.json") { - expand "version": project.version + expand "version": modVersion } } @@ -71,9 +79,15 @@ java { targetCompatibility = JavaVersion.VERSION_25 } +test { + useJUnitPlatform() +} + +def archivesName = base.archivesName + jar { from("LICENSE") { - rename { "${it}_${inputs.properties.archivesName}"} + rename { "${it}_${archivesName.get()}"} } } diff --git a/gradle.properties b/gradle.properties index 1703eb32..7acfc10f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,22 +1,27 @@ org.gradle.jvmargs=-Xmx2G # Fabric Properties -minecraft_version=26.1 -loader_version=0.18.4 +minecraft_version=26.2 +loader_version=0.19.3 # Mod Properties -mod_version = 2.9.4 +mod_version = 3.4 maven_group = io.icker archives_base_name = factions # Dependencies -fabric_api_version=0.144.1+26.1 +fabric_api_version=0.155.2+26.2 lucko_permissions_version=0.7.0 dynmap_api_version=3.7-SNAPSHOT -papi_version=3.0.0-beta.2+26.1 +papi_version=3.1.0-beta.1+26.2 styled_chat_version=V5imDrbM bluemap_api_version=2.7.1 squaremap_api_version=1.2.3 -sgui_version=2.0.0+26.1 -stapi_version=3.0.1+26.1 +sgui_version=2.1.0+26.2 +stapi_version=3.1.0+26.2 flow_version=1.0.3 + +# Build performance +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.parallel=true diff --git a/src/client/java/io/icker/factions/client/ClaimCache.java b/src/client/java/io/icker/factions/client/ClaimCache.java new file mode 100644 index 00000000..1b4c0271 --- /dev/null +++ b/src/client/java/io/icker/factions/client/ClaimCache.java @@ -0,0 +1,99 @@ +package io.icker.factions.client; + +import io.icker.factions.net.ClaimSyncPayload; +import net.minecraft.world.level.ChunkPos; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * Client-side cache of all faction claims. + * Populated on receipt of ClaimSyncPayload. Cleared on disconnect. + * + * Render hot path: zero allocation — just iterate prebuilt PackedRect lists. + */ +public final class ClaimCache { + private ClaimCache() {} + + /** + * Precomputed render data per dimension. + * Key = dimension id string (e.g. "minecraft:overworld"). + * Value = list of PackedRect, one per claimed chunk per faction. + * Replaced atomically on each payload receipt. + */ + private static volatile Map> byDim = Map.of(); + + /** Called from ClientPlayNetworking receiver — runs on the Netty IO thread, then schedules to client thread. */ + public static void update(ClaimSyncPayload payload) { + Map> newMap = new HashMap<>(); + for (ClaimSyncPayload.FactionClaims fc : payload.factions()) { + int rgb = fc.argbColor() & 0x00FFFFFF; + int argb = 0x45000000 | rgb; + for (ClaimSyncPayload.FactionClaims.DimClaims dc : fc.dims()) { + List rects = newMap.computeIfAbsent(dc.dimensionId(), k -> new ArrayList<>()); + mergeIntoRects(dc.chunkLongs(), argb, rects); + } + } + newMap.replaceAll((k, v) -> Collections.unmodifiableList(v)); + byDim = Collections.unmodifiableMap(newMap); + } + + // Greedy rectilinear cover: sweep row-major, grow each seed rightward + // then downward while every column is still in the set. O(n * avgArea). + private static void mergeIntoRects(List chunks, int argb, List out) { + if (chunks.isEmpty()) return; + HashSet remaining = new HashSet<>(chunks.size() * 2); + remaining.addAll(chunks); + List sorted = new ArrayList<>(chunks); + sorted.sort((a, b) -> { + int az = ChunkPos.getZ(a), bz = ChunkPos.getZ(b); + if (az != bz) return Integer.compare(az, bz); + return Integer.compare(ChunkPos.getX(a), ChunkPos.getX(b)); + }); + for (Long seed : sorted) { + if (!remaining.contains(seed)) continue; + int cx = ChunkPos.getX(seed); + int cz = ChunkPos.getZ(seed); + int w = 1; + while (remaining.contains(ChunkPos.pack(cx + w, cz))) w++; + int h = 1; + growH: while (true) { + for (int i = 0; i < w; i++) { + if (!remaining.contains(ChunkPos.pack(cx + i, cz + h))) break growH; + } + h++; + } + for (int j = 0; j < h; j++) { + for (int i = 0; i < w; i++) { + remaining.remove(ChunkPos.pack(cx + i, cz + j)); + } + } + out.add(new PackedRect( + cx << 4, + cz << 4, + ((cx + w) << 4) - 1, + ((cz + h) << 4) - 1, + argb)); + } + } + + /** Called on ClientPlayConnectionEvents.DISCONNECT. */ + public static void clear() { + byDim = Map.of(); + } + + /** + * Returns the precomputed rect list for the given dimension, or an empty list. + * Safe to call from render thread — volatile read + unmodifiable list. + */ + public static List getForDim(String dimensionId) { + return byDim.getOrDefault(dimensionId, List.of()); + } + + /** Immutable, allocation-free render data for one claimed chunk. */ + public record PackedRect(int minX, int minZ, int maxX, int maxZ, int argb) {} +} diff --git a/src/client/java/io/icker/factions/client/ClaimOverlayToggle.java b/src/client/java/io/icker/factions/client/ClaimOverlayToggle.java new file mode 100644 index 00000000..82390f75 --- /dev/null +++ b/src/client/java/io/icker/factions/client/ClaimOverlayToggle.java @@ -0,0 +1,45 @@ +package io.icker.factions.client; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; + +public final class ClaimOverlayToggle { + private ClaimOverlayToggle() {} + + private static volatile boolean worldMapEnabled = true; + private static volatile boolean inWorldMap; + private static KeyMapping toggleKey; + + public static boolean isWorldMapEnabled() { + return worldMapEnabled; + } + + public static void register() { + toggleKey = new KeyMapping( + "key.factions.toggle_worldmap_claims", + InputConstants.Type.KEYSYM, + InputConstants.KEY_J, + KeyMapping.Category.MISC); + KeyMappingHelper.registerKeyMapping(toggleKey); + ScreenEvents.AFTER_INIT.register((client, screen, w, h) -> { + if (!"xaero.map.gui.GuiMap".equals(screen.getClass().getName())) return; + inWorldMap = true; + ScreenEvents.remove(screen).register(s -> inWorldMap = false); + ScreenKeyboardEvents.afterKeyPress(screen).register((s, keyEvent) -> { + int bound = KeyMappingHelper.getBoundKeyOf(toggleKey).getValue(); + if (keyEvent.key() != bound) return; + worldMapEnabled = !worldMapEnabled; + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null) { + mc.player.sendOverlayMessage( + Component.literal("Faction claim overlay on World Map: " + (worldMapEnabled ? "ON" : "OFF"))); + } + }); + }); + } +} diff --git a/src/client/java/io/icker/factions/client/FactionsClientMod.java b/src/client/java/io/icker/factions/client/FactionsClientMod.java new file mode 100644 index 00000000..1a4015cc --- /dev/null +++ b/src/client/java/io/icker/factions/client/FactionsClientMod.java @@ -0,0 +1,29 @@ +package io.icker.factions.client; + +import io.icker.factions.net.ClaimSyncPayload; +import io.icker.factions.client.xaero.XaeroWorldMapWrapper; +import io.icker.factions.client.xaero.XaeroMinimapWrapper; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; + +public class FactionsClientMod implements ClientModInitializer { + @Override + public void onInitializeClient() { + ClientPlayNetworking.registerGlobalReceiver( + ClaimSyncPayload.TYPE, + (payload, context) -> context.client().execute(() -> ClaimCache.update(payload))); + + ClientPlayConnectionEvents.DISCONNECT.register( + (handler, client) -> ClaimCache.clear()); + + if (FabricLoader.getInstance().isModLoaded("xaeroworldmap")) { + XaeroWorldMapWrapper.register(); + ClaimOverlayToggle.register(); + } + if (FabricLoader.getInstance().isModLoaded("xaerominimap")) { + XaeroMinimapWrapper.register(); + } + } +} diff --git a/src/client/java/io/icker/factions/client/mixin/MixinGuiMap.java b/src/client/java/io/icker/factions/client/mixin/MixinGuiMap.java new file mode 100644 index 00000000..be351b29 --- /dev/null +++ b/src/client/java/io/icker/factions/client/mixin/MixinGuiMap.java @@ -0,0 +1,64 @@ +package io.icker.factions.client.mixin; + +import io.icker.factions.FactionsMod; +import io.icker.factions.client.ClaimCache; +import io.icker.factions.client.ClaimCache.PackedRect; +import io.icker.factions.client.ClaimOverlayToggle; +import java.util.List; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Pseudo +@Mixin(targets = "xaero.map.gui.GuiMap", remap = false) +public class MixinGuiMap { + @Shadow private double cameraX; + @Shadow private double cameraZ; + @Shadow private double scale; + @Shadow private double screenScale; + @Shadow private xaero.map.MapProcessor mapProcessor; + + private static boolean factions$warned; + + @Inject(method = "extractRenderState", at = @At("TAIL")) + private void factions$renderClaims( + GuiGraphicsExtractor extractor, int mouseX, int mouseY, float delta, CallbackInfo ci) { + try { + if (!ClaimOverlayToggle.isWorldMapEnabled()) return; + if (mapProcessor == null || scale <= 0.0 || screenScale <= 0.0) return; + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) return; + String dimId = mc.level.dimension().identifier().toString(); + List claims = ClaimCache.getForDim(dimId); + if (claims.isEmpty()) return; + + double pixelsPerBlock = scale / screenScale; + if (pixelsPerBlock * 16.0 < 0.5) return; + double centerX = extractor.guiWidth() * 0.5; + double centerZ = extractor.guiHeight() * 0.5; + double viewMinX = cameraX - centerX / pixelsPerBlock - 16.0; + double viewMaxX = cameraX + centerX / pixelsPerBlock + 16.0; + double viewMinZ = cameraZ - centerZ / pixelsPerBlock - 16.0; + double viewMaxZ = cameraZ + centerZ / pixelsPerBlock + 16.0; + for (PackedRect claim : claims) { + if (claim.maxX() + 1 < viewMinX || claim.minX() > viewMaxX) continue; + if (claim.maxZ() + 1 < viewMinZ || claim.minZ() > viewMaxZ) continue; + int minX = (int) Math.floor(centerX + (claim.minX() - cameraX) * pixelsPerBlock); + int minZ = (int) Math.floor(centerZ + (claim.minZ() - cameraZ) * pixelsPerBlock); + int maxX = (int) Math.ceil(centerX + (claim.maxX() + 1 - cameraX) * pixelsPerBlock); + int maxZ = (int) Math.ceil(centerZ + (claim.maxZ() + 1 - cameraZ) * pixelsPerBlock); + extractor.fill(minX, minZ, maxX, maxZ, claim.argb()); + } + } catch (RuntimeException exception) { + if (!factions$warned) { + factions$warned = true; + FactionsMod.LOGGER.warn("Unable to render faction claims on Xaero's World Map", exception); + } + } + } +} diff --git a/src/client/java/io/icker/factions/client/mixin/MixinMinimapRenderer.java b/src/client/java/io/icker/factions/client/mixin/MixinMinimapRenderer.java new file mode 100644 index 00000000..545723ea --- /dev/null +++ b/src/client/java/io/icker/factions/client/mixin/MixinMinimapRenderer.java @@ -0,0 +1,196 @@ +package io.icker.factions.client.mixin; + +import io.icker.factions.FactionsMod; +import io.icker.factions.client.ClaimCache; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import org.joml.Matrix3x2fStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import xaero.common.minimap.MinimapProcessor; +import xaero.hud.minimap.module.MinimapSession; +import xaero.hud.render.module.ModuleRenderContext; + +@Pseudo +@Environment(EnvType.CLIENT) +@Mixin(targets = "xaero.hud.minimap.module.MinimapRenderer", remap = false) +public class MixinMinimapRenderer { + private static boolean errorLogged; + private static final java.util.ArrayList visibleBuffer = new java.util.ArrayList<>(256); + + private static volatile int shapeInit; + private static Object shapeConfigOption; + private static Object northLockedConfigOption; + private static Method configGetEffective; + private static Field hudModInstance; + private static Method hudModGetHudConfigs; + private static Method configChannelGetClientConfigManager; + + private static Object readConfig(Object option) { + try { + if (shapeInit == 0) { + Class opts = Class.forName("xaero.hud.minimap.common.config.option.MinimapProfiledConfigOptions"); + shapeConfigOption = opts.getField("SHAPE").get(null); + northLockedConfigOption = opts.getField("NORTH_LOCKED").get(null); + Class hudMod = Class.forName("xaero.common.HudMod"); + hudModInstance = hudMod.getField("INSTANCE"); + hudModGetHudConfigs = hudMod.getMethod("getHudConfigs"); + Class configChannel = Class.forName("xaero.lib.common.config.channel.ConfigChannel"); + configChannelGetClientConfigManager = configChannel.getMethod("getClientConfigManager"); + Class ccm = Class.forName("xaero.lib.client.config.ClientConfigManager"); + Class optionCls = Class.forName("xaero.lib.common.config.option.ConfigOption"); + configGetEffective = ccm.getMethod("getEffective", optionCls); + shapeInit = 1; + } + if (shapeInit != 1 || option == null) return null; + Object inst = hudModInstance.get(null); + if (inst == null) return null; + Object chan = hudModGetHudConfigs.invoke(inst); + Object mgr = configChannelGetClientConfigManager.invoke(chan); + return configGetEffective.invoke(mgr, option); + } catch (Throwable t) { + shapeInit = 2; + return null; + } + } + + private static boolean isRoundMinimap() { + Object val = readConfig(shapeConfigOption); + if (val instanceof Integer i) return i != 0; + if (val instanceof Boolean b) return b; + return false; + } + + private static boolean isNorthLocked() { + Object val = readConfig(northLockedConfigOption); + if (val instanceof Boolean b) return b; + if (val instanceof Integer i) return i != 0; + return false; + } + + private static void drawClaims(java.util.List visible, GuiGraphicsExtractor graphics) { + for (ClaimCache.PackedRect claim : visible) { + graphics.fill(claim.minX(), claim.minZ(), claim.maxX() + 1, claim.maxZ() + 1, claim.argb()); + } + } + + @Inject(method = "render", at = @At("TAIL")) + private void onRender( + MinimapSession session, + ModuleRenderContext context, + GuiGraphicsExtractor graphics, + float tickDelta, + CallbackInfo ci) { + try { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null || minecraft.player == null) return; + + String dimensionId = minecraft.level.dimension().identifier().toString(); + var claims = ClaimCache.getForDim(dimensionId); + if (claims.isEmpty()) return; + + MinimapProcessor processor = session.getProcessor(); + if (processor.isCaveModeDisplayed()) return; + double blocksPerPixel = processor.getMinimapZoom(); + int minimapSize = processor.getMinimapSize(); + if (!(blocksPerPixel > 0.0) || minimapSize <= 0) return; + + double displayScale = (double) context.w / minimapSize; + float pixelsPerBlock = (float) (displayScale / blocksPerPixel); + float centerX = context.x + context.w * 0.5f; + float centerY = context.y + context.h * 0.5f; + float playerX = (float) minecraft.player.getX(tickDelta); + float playerZ = (float) minecraft.player.getZ(tickDelta); + float yawDeg = minecraft.player.getViewYRot(tickDelta); + float rot = isNorthLocked() ? 0f : (float) Math.toRadians(-(yawDeg + 180f)); + double blocksAcross = minimapSize * blocksPerPixel; + double viewRadius = blocksAcross * 0.75 + 32.0; + + int pad = 3; + Matrix3x2fStack pose = graphics.pose(); + boolean round = isRoundMinimap(); + + java.util.ArrayList visible = visibleBuffer; + visible.clear(); + for (ClaimCache.PackedRect claim : claims) { + if (claim.maxX() + 1 < playerX - viewRadius) continue; + if (claim.minX() > playerX + viewRadius) continue; + if (claim.maxZ() + 1 < playerZ - viewRadius) continue; + if (claim.minZ() > playerZ + viewRadius) continue; + visible.add(claim); + } + if (visible.isEmpty()) return; + + if (round) { + double radius = Math.min(context.w, context.h) * 0.5 - pad; + if (!(radius > 0)) return; + double radiusSq = radius * radius; + int rowTop = (int) Math.floor(centerY - radius); + int rowBottom = (int) Math.ceil(centerY + radius); + int stripH = 2; // strip height: 1=per-row (slow, smooth), higher=faster but slightly jagged + + for (int y = rowTop; y < rowBottom; y += stripH) { + int y1 = Math.min(y + stripH, rowBottom); + double topDy = y - centerY; + double botDy = (y1 - 1) - centerY; + double dyWorst = Math.max(Math.abs(topDy), Math.abs(botDy)); + double sq = radiusSq - dyWorst * dyWorst; + if (sq <= 0.0) continue; + double dx = Math.sqrt(sq); + int rx0 = (int) Math.ceil(centerX - dx); + int rx1 = (int) Math.floor(centerX + dx); + if (rx1 <= rx0) continue; + graphics.enableScissor(rx0, y, rx1, y1); + try { + pose.pushMatrix(); + try { + pose.translate(centerX, centerY); + pose.rotate(rot); + pose.scale(pixelsPerBlock, pixelsPerBlock); + pose.translate(-playerX, -playerZ); + for (ClaimCache.PackedRect claim : visible) { + graphics.fill(claim.minX(), claim.minZ(), claim.maxX() + 1, claim.maxZ() + 1, claim.argb()); + } + } finally { + pose.popMatrix(); + } + } finally { + graphics.disableScissor(); + } + } + } else { + int mx0 = context.x + pad, my0 = context.y + pad; + int mx1 = context.x + context.w - pad, my1 = context.y + context.h - pad; + graphics.enableScissor(mx0, my0, mx1, my1); + try { + pose.pushMatrix(); + try { + pose.translate(centerX, centerY); + pose.rotate(rot); + pose.scale(pixelsPerBlock, pixelsPerBlock); + pose.translate(-playerX, -playerZ); + for (ClaimCache.PackedRect claim : visible) { + graphics.fill(claim.minX(), claim.minZ(), claim.maxX() + 1, claim.maxZ() + 1, claim.argb()); + } + } finally { + pose.popMatrix(); + } + } finally { + graphics.disableScissor(); + } + } + } catch (Exception exception) { + if (!errorLogged) { + errorLogged = true; + FactionsMod.LOGGER.warn("Unable to render faction claims on Xaero's minimap", exception); + } + } + } +} diff --git a/src/client/resources/factions.client.mixins.json b/src/client/resources/factions.client.mixins.json new file mode 100644 index 00000000..1dc3b5b9 --- /dev/null +++ b/src/client/resources/factions.client.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "io.icker.factions.client.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [], + "client": [ + "MixinMinimapRenderer", + "MixinGuiMap" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/java/io/icker/factions/FactionsMod.java b/src/main/java/io/icker/factions/FactionsMod.java index 345636b9..61d8d9ab 100644 --- a/src/main/java/io/icker/factions/FactionsMod.java +++ b/src/main/java/io/icker/factions/FactionsMod.java @@ -8,6 +8,8 @@ import io.icker.factions.command.CreateCommand; import io.icker.factions.command.DeclareCommand; import io.icker.factions.command.DisbandCommand; +import io.icker.factions.command.GatherCommand; +import io.icker.factions.command.GrantCommand; import io.icker.factions.command.HomeCommand; import io.icker.factions.command.InfoCommand; import io.icker.factions.command.InviteCommand; @@ -20,15 +22,23 @@ import io.icker.factions.command.ModifyCommand; import io.icker.factions.command.PermissionCommand; import io.icker.factions.command.RankCommand; +import io.icker.factions.command.RulesCommand; import io.icker.factions.command.SafeCommand; import io.icker.factions.command.SettingsCommand; +import io.icker.factions.command.TeleportCommand; +import io.icker.factions.command.TradeCommand; +import io.icker.factions.command.XaeroSyncCommand; import io.icker.factions.config.Config; import io.icker.factions.core.ChatManager; +import io.icker.factions.core.ClaimSyncSender; import io.icker.factions.core.FactionsManager; import io.icker.factions.core.InteractionManager; import io.icker.factions.core.ServerManager; import io.icker.factions.core.SoundManager; +import io.icker.factions.core.TradeRequestManager; +import io.icker.factions.core.TradeSession; import io.icker.factions.core.WorldManager; +import io.icker.factions.net.PacketRegistry; import io.icker.factions.util.BlueMapWrapper; import io.icker.factions.util.Command; import io.icker.factions.util.DynmapWrapper; @@ -60,6 +70,7 @@ public void onInitialize() { LOGGER.info("Initialized Factions Mod"); WorldUtils.register(); + PacketRegistry.register(); dynmap = FabricLoader.getInstance().isModLoaded("dynmap") ? new DynmapWrapper() : null; bluemap = FabricLoader.getInstance().isModLoaded("bluemap") ? new BlueMapWrapper() : null; @@ -74,6 +85,9 @@ public void onInitialize() { ServerManager.register(); SoundManager.register(); WorldManager.register(); + ClaimSyncSender.register(); + TradeRequestManager.register(); + TradeSession.register(); CommandRegistrationCallback.EVENT.register(FactionsMod::registerCommands); } @@ -108,8 +122,14 @@ private static void registerCommands( new MemberCommand(), new ModifyCommand(), new RankCommand(), + new RulesCommand(), new SafeCommand(), - new PermissionCommand() + new PermissionCommand(), + new GrantCommand(), + new GatherCommand(), + new TeleportCommand(), + new TradeCommand(), + new XaeroSyncCommand() }; for (Command command : commands) { diff --git a/src/main/java/io/icker/factions/api/ClaimPermissions.java b/src/main/java/io/icker/factions/api/ClaimPermissions.java new file mode 100644 index 00000000..29bdd5d8 --- /dev/null +++ b/src/main/java/io/icker/factions/api/ClaimPermissions.java @@ -0,0 +1,199 @@ +package io.icker.factions.api; + +import io.icker.factions.FactionsMod; +import io.icker.factions.api.persistents.Claim; +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.GuestGrant; +import io.icker.factions.api.persistents.Relationship.Permissions; +import io.icker.factions.api.persistents.User; +import io.icker.factions.core.InteractionsUtil; +import io.icker.factions.core.InteractionsUtil.InteractionsUtilActions; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permission; +import net.minecraft.server.permissions.PermissionLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +import java.util.List; + +public final class ClaimPermissions { + private ClaimPermissions() {} + + public static boolean canPlaceBlock(ServerPlayer player, BlockPos pos) { + return canPlaceBlock(player, pos, ItemStack.EMPTY); + } + + public static boolean canPlaceBlock(ServerPlayer player, BlockPos pos, ItemStack usedItem) { + return check(player, pos, player.level(), Permissions.PLACE_BLOCKS, false, usedItem) + != InteractionResult.FAIL; + } + + public static boolean tryPlaceBlock(ServerPlayer player, BlockPos pos) { + return tryPlaceBlock(player, pos, ItemStack.EMPTY); + } + + public static boolean tryPlaceBlock(ServerPlayer player, BlockPos pos, ItemStack usedItem) { + boolean allowed = + check(player, pos, player.level(), Permissions.PLACE_BLOCKS, true, usedItem) + != InteractionResult.FAIL; + if (!allowed) { + InteractionsUtil.warn(player, InteractionsUtilActions.PLACE_BLOCKS); + } + return allowed; + } + + public static InteractionResult check( + Player player, BlockPos position, Level world, Permissions permission) { + return check(player, position, world, permission, false, ItemStack.EMPTY); + } + + public static InteractionResult check( + Player player, + BlockPos position, + Level world, + Permissions permission, + boolean consumeQuota) { + return check(player, position, world, permission, consumeQuota, ItemStack.EMPTY); + } + + public static InteractionResult check( + Player player, + BlockPos position, + Level world, + Permissions permission, + boolean consumeQuota, + ItemStack usedItem) { + if (!FactionsMod.CONFIG.CLAIM_PROTECTION) { + return InteractionResult.PASS; + } + + User user = User.get(player.getUUID()); + if (player.permissions() + .hasPermission( + new Permission.HasCommandLevel( + PermissionLevel.byId( + FactionsMod.CONFIG.REQUIRED_BYPASS_LEVEL))) + && user.bypass) { + return InteractionResult.PASS; + } + + Claim claim = Claim.get(world, position); + if (claim == null) return InteractionResult.PASS; + + Faction claimFaction = claim.getFaction(); + if (claimFaction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT + > claimFaction.getPower()) { + return InteractionResult.PASS; + } + + if (!user.isInFaction()) { + if (isRestrictedForGuests(usedItem)) { + return InteractionResult.FAIL; + } + + if (claimFaction.guest_permissions.contains(permission)) { + return InteractionResult.SUCCESS; + } + + GuestGrant grant = GuestGrant.get(claimFaction.getID(), user.getID()); + if (grant != null) { + if (permission == Permissions.BREAK_BLOCKS && grant.breakRemaining > 0) { + if (consumeQuota) { + grant.consumeBreak(); + saveGrant(grant); + } + return InteractionResult.SUCCESS; + } + if (permission == Permissions.PLACE_BLOCKS && grant.placeRemaining > 0) { + if (consumeQuota) { + grant.consumePlace(); + saveGrant(grant); + } + return InteractionResult.SUCCESS; + } + } + return InteractionResult.FAIL; + } + + Faction userFaction = user.getFaction(); + if (claimFaction.equals(userFaction) + && (rankLevel(claim.accessLevel) <= rankLevel(user.rank) + || (user.rank == User.Rank.GUEST + && claimFaction.guest_permissions.contains(permission) + && claim.accessLevel == User.Rank.MEMBER))) { + return InteractionResult.SUCCESS; + } + + if (FactionsMod.CONFIG.RELATIONSHIPS.ALLY_OVERRIDES_PERMISSIONS + && claimFaction.isMutualAllies(userFaction.getID()) + && claim.accessLevel == User.Rank.MEMBER) { + return InteractionResult.SUCCESS; + } + + if (claimFaction.getRelationship(userFaction.getID()).permissions.contains(permission) + && claim.accessLevel == User.Rank.MEMBER) { + return InteractionResult.SUCCESS; + } + + return InteractionResult.FAIL; + } + + public static boolean isRestrictedForGuests(ItemStack stack) { + if (stack == null || stack.isEmpty()) return false; + List restricted = FactionsMod.CONFIG.GUEST_GRANT.RESTRICTED_ITEMS; + if (restricted == null || restricted.isEmpty()) return false; + String id = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + return restricted.contains(id); + } + + public static boolean isNonMemberInClaim(Player player, BlockPos pos, Level world) { + if (!FactionsMod.CONFIG.CLAIM_PROTECTION) { + return false; + } + + User user = User.get(player.getUUID()); + if (player.permissions() + .hasPermission( + new Permission.HasCommandLevel( + PermissionLevel.byId( + FactionsMod.CONFIG.REQUIRED_BYPASS_LEVEL))) + && user.bypass) { + return false; + } + + Claim claim = Claim.get(world, pos); + if (claim == null) return false; + + Faction claimFaction = claim.getFaction(); + if (claimFaction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT + > claimFaction.getPower()) { + return false; + } + + return !user.isInFaction(); + } + + private static void saveGrant(GuestGrant grant) { + if (grant.breakRemaining == 0 && grant.placeRemaining == 0) { + grant.remove(); + GuestGrant.save(); + } else { + GuestGrant.saveThrottled(); + } + } + + private static int rankLevel(User.Rank rank) { + return switch (rank) { + case OWNER -> 3; + case LEADER -> 2; + case COMMANDER -> 1; + case MEMBER -> 0; + case GUEST -> -1; + }; + } +} diff --git a/src/main/java/io/icker/factions/api/persistents/Claim.java b/src/main/java/io/icker/factions/api/persistents/Claim.java index 5190d9e6..b7904dff 100644 --- a/src/main/java/io/icker/factions/api/persistents/Claim.java +++ b/src/main/java/io/icker/factions/api/persistents/Claim.java @@ -7,13 +7,19 @@ import io.icker.factions.database.Name; import io.icker.factions.util.WorldUtils; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; + import java.util.HashMap; import java.util.List; import java.util.UUID; @Name("Claim") public class Claim { - private static final HashMap STORE = Database.load(Claim.class, c -> c.getKey()); + public record ClaimKey(int x, int z, String level) {} + + private static final HashMap STORE = Database.load(Claim.class, Claim::getKey); + private static final FactionClaimCounts COUNTS_BY_FACTION = buildFactionCounts(); @Field("X") public int x; @@ -41,28 +47,34 @@ public Claim(int x, int z, String level, UUID factionID) { public Claim() {} - public String getKey() { - return String.format("%s-%d-%d", level, x, z); + public ClaimKey getKey() { + return new ClaimKey(x, z, level); } public static Claim get(int x, int z, String level) { - return STORE.get(String.format("%s-%d-%d", level, x, z)); + return STORE.get(new ClaimKey(x, z, level)); + } + + public static Claim get(Level world, BlockPos pos) { + return get(pos.getX() >> 4, pos.getZ() >> 4, WorldUtils.dimensionString(world)); } public static List getByFaction(UUID factionID) { return STORE.values().stream().filter(c -> c.factionID.equals(factionID)).toList(); } + public static int getCountByFaction(UUID factionID) { + return COUNTS_BY_FACTION.get(factionID); + } + public static void audit() { - STORE.values() - .removeIf( - (claim) -> - Faction.get(claim.factionID) == null - || !WorldUtils.isValid(claim.level)); + STORE.values().removeIf(Claim::isInvalid); + rebuildFactionCounts(); } public static void add(Claim claim) { - STORE.put(claim.getKey(), claim); + Claim replaced = STORE.put(claim.getKey(), claim); + COUNTS_BY_FACTION.replace(replaced == null ? null : replaced.factionID, claim.factionID); ClaimEvents.ADD.invoker().onAdd(claim); } @@ -71,11 +83,39 @@ public Faction getFaction() { } public void remove() { - STORE.remove(getKey()); - ClaimEvents.REMOVE.invoker().onRemove(x, z, level, Faction.get(factionID)); + Claim removed = STORE.remove(getKey()); + if (removed == null) return; + + COUNTS_BY_FACTION.remove(removed.factionID); + ClaimEvents.REMOVE + .invoker() + .onRemove( + removed.x, + removed.z, + removed.level, + Faction.get(removed.factionID)); } public static void save() { Database.save(Claim.class, STORE.values().stream().toList()); } + + private static FactionClaimCounts buildFactionCounts() { + FactionClaimCounts counts = new FactionClaimCounts(); + for (Claim claim : STORE.values()) { + counts.add(claim.factionID); + } + return counts; + } + + private static void rebuildFactionCounts() { + COUNTS_BY_FACTION.clear(); + for (Claim claim : STORE.values()) { + COUNTS_BY_FACTION.add(claim.factionID); + } + } + + private static boolean isInvalid(Claim claim) { + return Faction.get(claim.factionID) == null || !WorldUtils.isValid(claim.level); + } } diff --git a/src/main/java/io/icker/factions/api/persistents/Faction.java b/src/main/java/io/icker/factions/api/persistents/Faction.java index 93e78f6e..6d38fa4d 100644 --- a/src/main/java/io/icker/factions/api/persistents/Faction.java +++ b/src/main/java/io/icker/factions/api/persistents/Faction.java @@ -1,30 +1,32 @@ package io.icker.factions.api.persistents; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.stream.Stream; + +import org.jetbrains.annotations.Nullable; + import io.icker.factions.FactionsMod; import io.icker.factions.api.events.FactionEvents; import io.icker.factions.database.Database; import io.icker.factions.database.Field; import io.icker.factions.database.Name; import io.icker.factions.util.WorldUtils; - import net.minecraft.ChatFormatting; import net.minecraft.core.NonNullList; +import net.minecraft.network.chat.TextColor; import net.minecraft.world.SimpleContainer; import net.minecraft.world.item.ItemStack; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.UUID; -import java.util.stream.Stream; - @Name("Faction") public class Faction { - private static final HashMap STORE = - Database.load(Faction.class, Faction::getID); + + private static final HashMap STORE + = Database.load(Faction.class, Faction::getID); @Field("ID") private UUID id; @@ -41,7 +43,9 @@ public class Faction { @Field("Color") private String color; - /** Whether a player can join without an invitation */ + /** + * Whether a player can join without an invitation + */ @Field("Open") private boolean open; @@ -64,8 +68,18 @@ public class Faction { private ArrayList relationships = new ArrayList<>(); @Field("GuestPermissions") - public ArrayList guest_permissions = - new ArrayList<>(FactionsMod.CONFIG.RELATIONSHIPS.DEFAULT_GUEST_PERMISSIONS); + public ArrayList guest_permissions + = new ArrayList<>(FactionsMod.CONFIG.RELATIONSHIPS.DEFAULT_GUEST_PERMISSIONS); + + @Field("Rules") + public Rules rules = new Rules(); + + public static class Rules { + @Field("Elytra") + public boolean elytra = true; + + public Rules() {} + } public Faction( String name, @@ -78,12 +92,13 @@ public Faction( this.name = name; this.motd = motd; this.description = description; - this.color = color.getName(); + this.color = color.name().toLowerCase(Locale.ROOT); this.open = open; this.power = power; } - public Faction() {} + public Faction() { + } public Stream getRelationships() { return relationships.stream().filter(rel -> Faction.get(rel.target) != null); @@ -124,7 +139,15 @@ public String getName() { } public ChatFormatting getColor() { - return ChatFormatting.getByName(color); + return ChatFormatting.valueOf(color.toUpperCase(Locale.ROOT)); + } + + public String getColorName() { + return color; + } + + public int getColorValue() { + return TextColor.fromLegacyFormat(getColor()).getValue(); } public String getDescription() { @@ -169,7 +192,7 @@ public void setMOTD(String motd) { } public void setColor(ChatFormatting color) { - this.color = color.getName(); + this.color = color.name().toLowerCase(Locale.ROOT); FactionEvents.MODIFY.invoker().onModify(this); } @@ -183,7 +206,9 @@ public int adjustPower(int adjustment) { int newPower = Math.min(Math.max(0, power + adjustment), maxPower); int oldPower = this.power; - if (newPower == oldPower) return 0; + if (newPower == oldPower) { + return 0; + } power = newPower; FactionEvents.POWER_CHANGE.invoker().onPowerChange(this, oldPower); @@ -198,6 +223,10 @@ public void addAdminPower(int amount) { adminPower += amount; } + public boolean isElytraAllowed() { + return rules != null && rules.elytra; + } + public List getUsers() { return User.getByFaction(id); } @@ -206,6 +235,10 @@ public List getClaims() { return Claim.getByFaction(id); } + public int getClaimCount() { + return Claim.getCountByFaction(id); + } + public void removeAllClaims() { Claim.getByFaction(id).stream().forEach(Claim::remove); FactionEvents.REMOVE_ALL_CLAIMS.invoker().onRemoveAllClaims(this); @@ -262,8 +295,8 @@ public List getEnemiesOf() { } public void removeRelationship(UUID target) { - relationships = - new ArrayList<>( + relationships + = new ArrayList<>( this.getRelationships().filter(rel -> !rel.target.equals(target)).toList()); } @@ -272,7 +305,9 @@ public void setRelationship(Relationship relationship) { removeRelationship(relationship.target); } if (relationship.status != Relationship.Status.NEUTRAL - || !relationship.permissions.isEmpty()) relationships.add(relationship); + || !relationship.permissions.isEmpty()) { + relationships.add(relationship); + } } public void remove() { @@ -286,14 +321,21 @@ public void remove() { } } removeAllClaims(); + for (GuestGrant grant : GuestGrant.getByFaction(id)) { + grant.remove(); + } STORE.remove(id); FactionEvents.DISBAND.invoker().onDisband(this); } @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } Faction faction = (Faction) o; return id.equals(faction.id); } diff --git a/src/main/java/io/icker/factions/api/persistents/FactionClaimCounts.java b/src/main/java/io/icker/factions/api/persistents/FactionClaimCounts.java new file mode 100644 index 00000000..2f68a71c --- /dev/null +++ b/src/main/java/io/icker/factions/api/persistents/FactionClaimCounts.java @@ -0,0 +1,34 @@ +package io.icker.factions.api.persistents; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +final class FactionClaimCounts { + private final Map counts = new HashMap<>(); + + int get(UUID factionID) { + return counts.getOrDefault(factionID, 0); + } + + void add(UUID factionID) { + counts.merge(factionID, 1, Integer::sum); + } + + void replace(UUID previousFactionID, UUID factionID) { + if (factionID.equals(previousFactionID)) return; + + if (previousFactionID != null) { + remove(previousFactionID); + } + add(factionID); + } + + void remove(UUID factionID) { + counts.computeIfPresent(factionID, (id, count) -> count == 1 ? null : count - 1); + } + + void clear() { + counts.clear(); + } +} diff --git a/src/main/java/io/icker/factions/api/persistents/GuestGrant.java b/src/main/java/io/icker/factions/api/persistents/GuestGrant.java new file mode 100644 index 00000000..d465e1fd --- /dev/null +++ b/src/main/java/io/icker/factions/api/persistents/GuestGrant.java @@ -0,0 +1,98 @@ +package io.icker.factions.api.persistents; + +import io.icker.factions.database.Database; +import io.icker.factions.database.Field; +import io.icker.factions.database.Name; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +@Name("GuestGrant") +public class GuestGrant { + private static final HashMap STORE = + Database.load(GuestGrant.class, GuestGrant::getKey); + + /** Worst-case window of quota loss on an unclean shutdown. Lower = safer, more writes. */ + private static final long FLUSH_INTERVAL_MILLIS = 30_000L; + + private static boolean dirty; + private static long lastFlushMillis; + + @Field("ID") + private UUID id; + + @Field("FactionID") + public UUID factionID; + + @Field("PlayerID") + public UUID playerID; + + @Field("BreakRemaining") + public int breakRemaining; + + @Field("PlaceRemaining") + public int placeRemaining; + + public GuestGrant(UUID factionID, UUID playerID, int breakRemaining, int placeRemaining) { + this.id = UUID.randomUUID(); + this.factionID = factionID; + this.playerID = playerID; + this.breakRemaining = breakRemaining; + this.placeRemaining = placeRemaining; + } + + public GuestGrant() {} + + public String getKey() { + return key(factionID, playerID); + } + + public static String key(UUID factionID, UUID playerID) { + return factionID + "-" + playerID; + } + + public static GuestGrant get(UUID factionID, UUID playerID) { + return STORE.get(key(factionID, playerID)); + } + + public static List getByFaction(UUID factionID) { + return STORE.values().stream().filter(g -> g.factionID.equals(factionID)).toList(); + } + + public static void add(GuestGrant grant) { + STORE.put(grant.getKey(), grant); + dirty = true; + } + + public void consumeBreak() { + breakRemaining--; + dirty = true; + } + + public void consumePlace() { + placeRemaining--; + dirty = true; + } + + public void remove() { + if (STORE.remove(getKey()) != null) { + dirty = true; + } + } + + public static void save() { + if (!dirty) return; + + Database.save(GuestGrant.class, STORE.values().stream().toList()); + dirty = false; + lastFlushMillis = System.currentTimeMillis(); + } + + public static void saveThrottled() { + if (!dirty) return; + if (System.currentTimeMillis() - lastFlushMillis < FLUSH_INTERVAL_MILLIS) return; + + save(); + } +} diff --git a/src/main/java/io/icker/factions/api/persistents/User.java b/src/main/java/io/icker/factions/api/persistents/User.java index b5e2ecbe..59ec860f 100644 --- a/src/main/java/io/icker/factions/api/persistents/User.java +++ b/src/main/java/io/icker/factions/api/persistents/User.java @@ -50,9 +50,6 @@ public enum SoundMode { @Field("Rank") public Rank rank; - @Field("Radar") - public boolean radar = false; - @Field("Chat") public ChatMode chat = ChatMode.GLOBAL; @@ -79,10 +76,7 @@ public String getKey() { @NotNull public static User get(UUID id) { - if (!STORE.containsKey(id)) { - User.add(new User(id)); - } - return STORE.get(id); + return STORE.computeIfAbsent(id, User::new); } public static List getByFaction(UUID factionID) { diff --git a/src/main/java/io/icker/factions/command/ClaimCommand.java b/src/main/java/io/icker/factions/command/ClaimCommand.java index 6d6882f3..b700ded6 100644 --- a/src/main/java/io/icker/factions/command/ClaimCommand.java +++ b/src/main/java/io/icker/factions/command/ClaimCommand.java @@ -11,6 +11,7 @@ import io.icker.factions.api.persistents.User; import io.icker.factions.util.Command; import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; @@ -68,38 +69,43 @@ private int list(CommandContext context) throws CommandSynta return 1; } - private int addForced(CommandContext context, int size) + private int addForced(CommandContext context, int size, boolean checkPower) throws CommandSyntaxException { CommandSourceStack source = context.getSource(); ServerPlayer player = source.getPlayerOrException(); ServerLevel world = (ServerLevel) player.level(); - Faction faction = Command.getUser(player).getFaction(); - String dimension = world.dimension().identifier().toString(); + User user = Command.getUser(player); + Faction faction = user.getFaction(); + String dimension = WorldUtils.dimensionString(world); ArrayList chunks = new ArrayList(); + int newChunkCount = 0; for (int x = -size + 1; x < size; x++) { for (int y = -size + 1; y < size; y++) { ChunkPos chunkPos = - world.getChunk(player.blockPosition().offset(x * 16, 0, y * 16)).getPos(); + WorldUtils.getChunkPos( + player.blockPosition().offset(x * 16, 0, y * 16)); Claim existingClaim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); if (existingClaim != null) { if (size == 1) { - boolean isActorOwner = existingClaim.getFaction().equals(faction); - new Message( - Component.translatable( - "factions.command.claim.add.fail.already_owned.single", - Component.translatable( - "factions.command.claim.add.fail.already_owned.single." - + (isActorOwner - ? "your" - : "another")))) - .fail() - .send(player, false); - return 0; - } else if (!existingClaim.getFaction().equals(faction)) { + if (!user.bypass) { + boolean isActorOwner = existingClaim.getFaction().equals(faction); + new Message( + Component.translatable( + "factions.command.claim.add.fail.already_owned.single", + Component.translatable( + "factions.command.claim.add.fail.already_owned.single." + + (isActorOwner + ? "your" + : "another")))) + .fail() + .send(player, false); + return 0; + } + } else if (!user.bypass && !existingClaim.getFaction().equals(faction)) { new Message( Component.translatable( "factions.command.claim.add.fail.already_owned.multiple")) @@ -107,12 +113,35 @@ private int addForced(CommandContext context, int size) .send(player, false); return 0; } + } else { + newChunkCount++; } chunks.add(chunkPos); } } + if (checkPower) { + int requiredPower = + (faction.getClaimCount() + newChunkCount) + * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; + int maxPower = + faction.getUsers().size() * FactionsMod.CONFIG.POWER.MEMBER + + FactionsMod.CONFIG.POWER.BASE + + faction.getAdminPower(); + + if (maxPower < requiredPower) { + new Message( + Component.translatable( + size == 1 + ? "factions.command.claim.add.fail.lacks_power" + : "factions.command.claim.add.fail.lacks_power.multiple")) + .fail() + .send(player, false); + return 0; + } + } + chunks.forEach(chunk -> faction.addClaim(chunk.x(), chunk.z(), dimension)); if (size == 1) { new Message( @@ -138,48 +167,12 @@ private int addForced(CommandContext context, int size) } private int add(CommandContext context) throws CommandSyntaxException { - ServerPlayer player = context.getSource().getPlayerOrException(); - Faction faction = Command.getUser(player).getFaction(); - - int requiredPower = - (faction.getClaims().size() + 1) * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; - int maxPower = - faction.getUsers().size() * FactionsMod.CONFIG.POWER.MEMBER - + FactionsMod.CONFIG.POWER.BASE - + faction.getAdminPower(); - - if (maxPower < requiredPower) { - new Message(Component.translatable("factions.command.claim.add.fail.lacks_power")) - .fail() - .send(player, false); - return 0; - } - - return addForced(context, 1); + return addForced(context, 1, true); } private int addSize(CommandContext context) throws CommandSyntaxException { int size = IntegerArgumentType.getInteger(context, "size"); - ServerPlayer player = context.getSource().getPlayerOrException(); - Faction faction = Command.getUser(player).getFaction(); - - int requiredPower = - (faction.getClaims().size() + 1) * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; - int maxPower = - faction.getUsers().size() * FactionsMod.CONFIG.POWER.MEMBER - + FactionsMod.CONFIG.POWER.BASE - + faction.getAdminPower(); - - if (maxPower < requiredPower) { - new Message( - Component.translatable( - "factions.command.claim.add.fail.lacks_power.multiple")) - .fail() - .send(player, false); - return 0; - } - - return addForced(context, size); + return addForced(context, size, true); } private int remove(CommandContext context) throws CommandSyntaxException { @@ -188,10 +181,7 @@ private int remove(CommandContext context) throws CommandSyn ServerPlayer player = source.getPlayerOrException(); ServerLevel world = (ServerLevel) player.level(); - ChunkPos chunkPos = world.getChunk(player.blockPosition()).getPos(); - String dimension = world.dimension().identifier().toString(); - - Claim existingClaim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); + Claim existingClaim = Claim.get(world, player.blockPosition()); if (existingClaim == null) { new Message(Component.translatable("factions.command.claim.remove.fail.unclaimed")) @@ -228,7 +218,7 @@ private int removeSize(CommandContext context) ServerPlayer player = source.getPlayerOrException(); ServerLevel world = (ServerLevel) player.level(); - String dimension = world.dimension().identifier().toString(); + String dimension = WorldUtils.dimensionString(world); User user = Command.getUser(player); Faction faction = user.getFaction(); @@ -236,7 +226,8 @@ private int removeSize(CommandContext context) for (int x = -size + 1; x < size; x++) { for (int y = -size + 1; y < size; y++) { ChunkPos chunkPos = - world.getChunk(player.blockPosition().offset(x * 16, 0, y * 16)).getPos(); + WorldUtils.getChunkPos( + player.blockPosition().offset(x * 16, 0, y * 16)); Claim existingClaim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); if (existingClaim != null @@ -246,8 +237,8 @@ private int removeSize(CommandContext context) } ChunkPos chunkPos = - world.getChunk(player.blockPosition().offset((-size + 1) * 16, 0, (-size + 1) * 16)) - .getPos(); + WorldUtils.getChunkPos( + player.blockPosition().offset((-size + 1) * 16, 0, (-size + 1) * 16)); new Message( Component.translatable( "factions.command.claim.remove.success.multiple", @@ -304,10 +295,7 @@ private int setAccessLevel(CommandContext context, boolean i ServerPlayer player = source.getPlayerOrException(); ServerLevel world = (ServerLevel) player.level(); - ChunkPos chunkPos = world.getChunk(player.blockPosition()).getPos(); - String dimension = world.dimension().identifier().toString(); - - Claim claim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); + Claim claim = Claim.get(world, player.blockPosition()); if (claim == null) { new Message( @@ -398,7 +386,7 @@ private int setAccessLevel(CommandContext context, boolean i @Override public LiteralCommandNode getNode() { return Commands.literal("claim") - .requires(Requires.isCommander()) + .requires(Requires.isLeader()) .then( Commands.literal("add") .requires(Requires.hasPerms("factions.claim.add", 0)) @@ -423,7 +411,8 @@ public LiteralCommandNode getNode() { IntegerArgumentType .getInteger( context, - "size")))) + "size"), + false))) .executes(this::addSize)) .executes(this::add)) .then( diff --git a/src/main/java/io/icker/factions/command/GatherCommand.java b/src/main/java/io/icker/factions/command/GatherCommand.java new file mode 100644 index 00000000..a37467ef --- /dev/null +++ b/src/main/java/io/icker/factions/command/GatherCommand.java @@ -0,0 +1,71 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.FactionsMod; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.HashSet; + +public class GatherCommand implements Command { + + private int go(CommandContext context) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerPlayer player = source.getPlayerOrException(); + + if (player == null) return 0; + + return execGo(player); + } + + public int execGo(ServerPlayer player) { + if (player.level().getServer() == null) return 0; + + ServerLevel world = WorldUtils.getWorld(FactionsMod.CONFIG.GATHER.LEVEL); + + if (world == null) { + new Message(Component.translatable("factions.command.gather.fail.no_world")) + .fail() + .send(player, false); + return 0; + } + + player.teleportTo( + world, + FactionsMod.CONFIG.GATHER.X, + FactionsMod.CONFIG.GATHER.Y, + FactionsMod.CONFIG.GATHER.Z, + new HashSet<>(), + FactionsMod.CONFIG.GATHER.YAW, + FactionsMod.CONFIG.GATHER.PITCH, + false); + + new Message(Component.translatable("factions.command.gather.success")) + .send(player, false); + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("gather") + .requires( + Requires.multiple( + Requires.isMember(), + s -> FactionsMod.CONFIG.GATHER != null + && FactionsMod.CONFIG.GATHER.ENABLED, + Requires.hasPerms("factions.gather", 0))) + .executes(this::go) + .build(); + } +} + diff --git a/src/main/java/io/icker/factions/command/GrantCommand.java b/src/main/java/io/icker/factions/command/GrantCommand.java new file mode 100644 index 00000000..ac1488cd --- /dev/null +++ b/src/main/java/io/icker/factions/command/GrantCommand.java @@ -0,0 +1,193 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.FactionsMod; +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.GuestGrant; +import io.icker.factions.api.persistents.User; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public class GrantCommand implements Command { + private UUID resolvePlayer(CommandContext context, String name) + throws CommandSyntaxException { + ServerPlayer target = context.getSource().getServer().getPlayerList().getPlayerByName(name); + return target == null ? null : target.getUUID(); + } + + private boolean hasFactionMember(Faction faction, UUID playerID) { + User user = User.get(playerID); + return user.isInFaction() + && user.getFaction() != null + && user.getFaction().getID().equals(faction.getID()); + } + + private int grant(CommandContext context, boolean breakBlocks) + throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + User actorUser = Command.getUser(player); + Faction faction = actorUser.getFaction(); + String name = StringArgumentType.getString(context, "player"); + int amount = IntegerArgumentType.getInteger(context, "amount"); + UUID targetID = resolvePlayer(context, name); + + if (targetID == null) { + new Message( + Component.translatable( + "factions.command.grant.fail.player_not_found")) + .fail() + .send(player, false); + return 0; + } + + if (hasFactionMember(faction, targetID)) { + new Message( + Component.translatable( + "factions.command.grant.fail.already_member")) + .fail() + .send(player, false); + return 0; + } + + int maximum = + breakBlocks + ? FactionsMod.CONFIG.GUEST_GRANT.MAX_BREAK + : FactionsMod.CONFIG.GUEST_GRANT.MAX_PLACE; + GuestGrant grant = GuestGrant.get(faction.getID(), targetID); + int remaining = grant == null ? 0 : (breakBlocks ? grant.breakRemaining : grant.placeRemaining); + if (remaining > maximum || amount > maximum - remaining) { + new Message( + Component.translatable( + "factions.command.grant.fail.max_exceeded")) + .fail() + .send(player, false); + return 0; + } + + if (grant == null) { + grant = + new GuestGrant( + faction.getID(), + targetID, + breakBlocks ? amount : 0, + breakBlocks ? 0 : amount); + } else if (breakBlocks) { + grant.breakRemaining += amount; + } else { + grant.placeRemaining += amount; + } + + GuestGrant.add(grant); + GuestGrant.save(); + new Message( + Component.translatable( + breakBlocks + ? "factions.command.grant.success.break" + : "factions.command.grant.success.place")) + .send(player, false); + return 1; + } + + private int grantBreak(CommandContext context) + throws CommandSyntaxException { + return grant(context, true); + } + + private int grantPlace(CommandContext context) + throws CommandSyntaxException { + return grant(context, false); + } + + private int revoke(CommandContext context) throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + Faction faction = Command.getUser(player).getFaction(); + UUID targetID = resolvePlayer(context, StringArgumentType.getString(context, "player")); + + if (targetID == null) { + new Message( + Component.translatable( + "factions.command.grant.fail.player_not_found")) + .fail() + .send(player, false); + return 0; + } + + GuestGrant grant = GuestGrant.get(faction.getID(), targetID); + if (grant == null) { + new Message( + Component.translatable("factions.command.grant.revoke.fail.no_grant")) + .fail() + .send(player, false); + return 0; + } + + grant.remove(); + GuestGrant.save(); + new Message(Component.translatable("factions.command.grant.revoke.success")) + .send(player, false); + return 1; + } + + private int list(CommandContext context) throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + Faction faction = Command.getUser(player).getFaction(); + new Message(Component.translatable("factions.command.grant.list.header")) + .send(player, false); + + for (GuestGrant grant : GuestGrant.getByFaction(faction.getID())) { + ServerPlayer grantedPlayer = + context.getSource().getServer().getPlayerList().getPlayer(grant.playerID); + String name = + grantedPlayer != null ? grantedPlayer.getName().getString() : grant.playerID.toString(); + new Message( + Component.translatable( + "factions.command.grant.list.entry", + name, + grant.breakRemaining, + grant.placeRemaining)) + .send(player, false); + } + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("grant") + .requires(Requires.isLeader()) + .then( + Commands.literal("revoke") + .then( + Commands.argument("player", StringArgumentType.word()) + .executes(this::revoke))) + .then(Commands.literal("list").executes(this::list)) + .then( + Commands.argument("player", StringArgumentType.word()) + .then( + Commands.literal("break") + .then( + Commands.argument( + "amount", + IntegerArgumentType.integer(1)) + .executes(this::grantBreak))) + .then( + Commands.literal("place") + .then( + Commands.argument( + "amount", + IntegerArgumentType.integer(1)) + .executes(this::grantPlace)))) + .build(); + } +} diff --git a/src/main/java/io/icker/factions/command/HomeCommand.java b/src/main/java/io/icker/factions/command/HomeCommand.java index 5a58fd71..79ab310b 100644 --- a/src/main/java/io/icker/factions/command/HomeCommand.java +++ b/src/main/java/io/icker/factions/command/HomeCommand.java @@ -20,7 +20,6 @@ import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.level.ChunkPos; import java.time.Instant; import java.util.Date; @@ -123,7 +122,7 @@ private int set(CommandContext context) throws CommandSyntax player.getZ(), player.getYHeadRot(), player.getXRot(), - player.level().dimension().identifier().toString()); + WorldUtils.dimensionString(player.level())); faction.setHome(home); new Message( @@ -140,10 +139,7 @@ private int set(CommandContext context) throws CommandSyntax private static boolean checkLimitToClaim(Faction faction, ServerLevel world, BlockPos pos) { if (!FactionsMod.CONFIG.HOME.CLAIM_ONLY) return false; - ChunkPos chunkPos = world.getChunk(pos).getPos(); - String dimension = world.dimension().identifier().toString(); - - Claim possibleClaim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); + Claim possibleClaim = Claim.get(world, pos); return possibleClaim == null || possibleClaim.getFaction().getID() != faction.getID(); } diff --git a/src/main/java/io/icker/factions/command/InfoCommand.java b/src/main/java/io/icker/factions/command/InfoCommand.java index 5291e544..4e882054 100644 --- a/src/main/java/io/icker/factions/command/InfoCommand.java +++ b/src/main/java/io/icker/factions/command/InfoCommand.java @@ -103,7 +103,7 @@ public static int info(ServerPlayer player, Faction faction) { .map(fac -> fac.getColor() + fac.getName()) .collect(Collectors.joining(ChatFormatting.GRAY + ", ")); - int requiredPower = faction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; + int requiredPower = faction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; int maxPower = users.size() * FactionsMod.CONFIG.POWER.MEMBER + FactionsMod.CONFIG.POWER.BASE; diff --git a/src/main/java/io/icker/factions/command/MapCommand.java b/src/main/java/io/icker/factions/command/MapCommand.java index c1c2a463..67ec02d8 100644 --- a/src/main/java/io/icker/factions/command/MapCommand.java +++ b/src/main/java/io/icker/factions/command/MapCommand.java @@ -8,6 +8,7 @@ import io.icker.factions.api.persistents.Faction; import io.icker.factions.util.Command; import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; @@ -24,8 +25,8 @@ private int run(CommandContext context) throws CommandSyntax ServerPlayer player = source.getPlayerOrException(); ServerLevel world = (ServerLevel) player.level(); - ChunkPos chunkPos = world.getChunk(player.blockPosition()).getPos(); - String dimension = world.dimension().identifier().toString(); + ChunkPos chunkPos = WorldUtils.getChunkPos(player.blockPosition()); + String dimension = WorldUtils.dimensionString(world); // Print the header of the faction map. new Message( diff --git a/src/main/java/io/icker/factions/command/ModifyCommand.java b/src/main/java/io/icker/factions/command/ModifyCommand.java index d9c8dd3b..e9abe3db 100644 --- a/src/main/java/io/icker/factions/command/ModifyCommand.java +++ b/src/main/java/io/icker/factions/command/ModifyCommand.java @@ -1,5 +1,7 @@ package io.icker.factions.command; +import java.util.Locale; + import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; @@ -11,20 +13,18 @@ import io.icker.factions.ui.ModifyGui; import io.icker.factions.util.Command; import io.icker.factions.util.Message; - import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; -import net.minecraft.commands.arguments.ColorArgument; +import net.minecraft.commands.arguments.TeamColorArgument; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerPlayer; - +import net.minecraft.world.scores.TeamColor; import xyz.nucleoid.server.translations.api.Localization; -import java.util.Locale; - public class ModifyCommand implements Command { + private int gui(CommandContext context) throws CommandSyntaxException { ServerPlayer player = context.getSource().getPlayerOrException(); Faction faction = Command.getUser(player).getFaction(); @@ -87,8 +87,8 @@ private int description(CommandContext context) faction.setDescription(description); new Message( - Component.translatable( - "factions.gui.modify.change_description.result", description)) + Component.translatable( + "factions.gui.modify.change_description.result", description)) .prependFaction(faction) .send(player, false); @@ -112,27 +112,29 @@ private int motd(CommandContext context) throws CommandSynta } private int color(CommandContext context) throws CommandSyntaxException { - ChatFormatting color = ColorArgument.getColor(context, "color"); + TeamColor color = TeamColorArgument.getTeamColor(context, "color"); + ChatFormatting formatting = ChatFormatting.valueOf(color.name()); CommandSourceStack source = context.getSource(); ServerPlayer player = source.getPlayerOrException(); Faction faction = Command.getUser(player).getFaction(); - faction.setColor(color); + faction.setColor(formatting); - if (color.equals(ChatFormatting.RESET)) { + if (formatting.equals(ChatFormatting.RESET)) { new Message(Component.translatable("factions.gui.modify.change_color.result.reset")) .prependFaction(faction) .send(player, false); } else { new Message( + Component.translatable( + "factions.gui.modify.change_color.result.color", Component.translatable( - "factions.gui.modify.change_color.result.color", - Component.translatable( - "factions.gui.modify.change_color.color." - + color.name().toLowerCase()) - .setStyle(Style.EMPTY.withColor(color).withBold(true)))) + "factions.gui.modify.change_color.color." + + formatting.name().toLowerCase()) + .setStyle( + Style.EMPTY.withColor(formatting).withBold(true)))) .prependFaction(faction) .send(player, false); } @@ -152,9 +154,9 @@ private int open(CommandContext context) throws CommandSynta new Message(Component.translatable("factions.command.modify.open.success")) .add( new Message( - Component.translatable( - "factions.gui.modify.faction_type." - + (open ? "public" : "invite"))) + Component.translatable( + "factions.gui.modify.faction_type." + + (open ? "public" : "invite"))) .format(open ? ChatFormatting.GREEN : ChatFormatting.RED)) .prependFaction(faction) .send(player, false); @@ -183,8 +185,8 @@ public LiteralCommandNode getNode() { .requires(Requires.hasPerms("factions.modify.description", 0)) .then( Commands.argument( - "description", - StringArgumentType.greedyString()) + "description", + StringArgumentType.greedyString()) .executes(this::description))) .then( Commands.literal("motd") @@ -196,7 +198,7 @@ public LiteralCommandNode getNode() { Commands.literal("color") .requires(Requires.hasPerms("factions.modify.color", 0)) .then( - Commands.argument("color", ColorArgument.color()) + Commands.argument("color", TeamColorArgument.teamColor()) .executes(this::color))) .then( Commands.literal("open") diff --git a/src/main/java/io/icker/factions/command/RulesCommand.java b/src/main/java/io/icker/factions/command/RulesCommand.java new file mode 100644 index 00000000..6e7e8f01 --- /dev/null +++ b/src/main/java/io/icker/factions/command/RulesCommand.java @@ -0,0 +1,56 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.User; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +public class RulesCommand implements Command { + + private int setElytra(CommandContext context, boolean value) + throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + User user = Command.getUser(player); + Faction faction = user.getFaction(); + + if (faction.rules == null) { + faction.rules = new Faction.Rules(); + } + faction.rules.elytra = value; + + new Message( + Component.translatable( + value + ? "factions.command.rules.elytra.enabled" + : "factions.command.rules.elytra.disabled")) + .send(player, false); + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("rules") + .requires( + Requires.multiple( + Requires.isLeader(), + Requires.hasPerms("factions.rules", 0))) + .then( + Commands.literal("elytra") + .then( + Commands.literal("enabled") + .executes(ctx -> setElytra(ctx, true))) + .then( + Commands.literal("disabled") + .executes(ctx -> setElytra(ctx, false)))) + .build(); + } +} diff --git a/src/main/java/io/icker/factions/command/SettingsCommand.java b/src/main/java/io/icker/factions/command/SettingsCommand.java index 6c1c5020..04bde670 100644 --- a/src/main/java/io/icker/factions/command/SettingsCommand.java +++ b/src/main/java/io/icker/factions/command/SettingsCommand.java @@ -53,24 +53,6 @@ private int setSounds(CommandContext context, User.SoundMode return 1; } - private int radar(CommandContext context) throws CommandSyntaxException { - CommandSourceStack source = context.getSource(); - ServerPlayer player = source.getPlayerOrException(); - - User config = User.get(player.getUUID()); - boolean radar = !config.radar; - config.radar = radar; - - new Message(Component.translatable("factions.command.settings.radar")) - .filler("·") - .add( - new Message(Component.translatable("options." + (radar ? "on" : "off"))) - .format(radar ? ChatFormatting.GREEN : ChatFormatting.RED)) - .send(player, false); - - return 1; - } - public LiteralCommandNode getNode() { return Commands.literal("settings") .requires(Requires.hasPerms("factions.settings", 0)) @@ -98,10 +80,6 @@ public LiteralCommandNode getNode() { setChat( context, User.ChatMode.FOCUS)))) - .then( - Commands.literal("radar") - .requires(Requires.hasPerms("factions.settings.radar", 0)) - .executes(this::radar)) .then( Commands.literal("sounds") .requires(Requires.hasPerms("factions.settings.sounds", 0)) diff --git a/src/main/java/io/icker/factions/command/TeleportCommand.java b/src/main/java/io/icker/factions/command/TeleportCommand.java new file mode 100644 index 00000000..222cdff2 --- /dev/null +++ b/src/main/java/io/icker/factions/command/TeleportCommand.java @@ -0,0 +1,224 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.FactionsMod; +import io.icker.factions.api.persistents.User; +import io.icker.factions.core.TeleportRequestManager; +import io.icker.factions.core.TeleportRequestManager.Request; +import io.icker.factions.mixin.CombatTrackerAccessor; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.HashSet; +import java.util.UUID; + +public class TeleportCommand implements Command { + private int request(CommandContext context) throws CommandSyntaxException { + ServerPlayer requester = context.getSource().getPlayerOrException(); + String name = StringArgumentType.getString(context, "player"); + ServerPlayer target = + context.getSource().getServer().getPlayerList().getPlayerByName(name); + + if (target == null) { + new Message(Component.translatable("factions.command.tp.fail.offline")) + .fail() + .send(requester, false); + return 0; + } + + if (target.getUUID().equals(requester.getUUID())) { + new Message(Component.translatable("factions.command.tp.fail.self")) + .fail() + .send(requester, false); + return 0; + } + + User requesterUser = Command.getUser(requester); + User targetUser = Command.getUser(target); + if (!requesterUser.isInFaction() + || !targetUser.isInFaction() + || !requesterUser.getFaction().getID().equals(targetUser.getFaction().getID())) { + new Message(Component.translatable("factions.command.tp.fail.not_member")) + .fail() + .send(requester, false); + return 0; + } + + long now = System.currentTimeMillis(); + TeleportRequestManager.request(target.getUUID(), requester.getUUID(), now); + + new Message( + Component.translatable( + "factions.command.tp.request.sent", + target.getName().getString())) + .send(requester, false); + new Message( + Component.translatable( + "factions.command.tp.request.received", + requester.getName().getString(), + FactionsMod.CONFIG.TELEPORT.REQUEST_EXPIRY_SECONDS)) + .hover(Component.translatable("factions.command.tp.request.hover")) + .click("/factions tp accept") + .send(target, false); + return 1; + } + + private int accept(CommandContext context) throws CommandSyntaxException { + ServerPlayer target = context.getSource().getPlayerOrException(); + UUID targetID = target.getUUID(); + + Request req = TeleportRequestManager.peek(targetID); + if (req == null) { + new Message(Component.translatable("factions.command.tp.accept.fail.no_request")) + .fail() + .send(target, false); + return 0; + } + + long now = System.currentTimeMillis(); + int expirySeconds = FactionsMod.CONFIG.TELEPORT.REQUEST_EXPIRY_SECONDS; + if (TeleportRequestManager.isExpired(req, now, expirySeconds)) { + TeleportRequestManager.deny(targetID); + new Message(Component.translatable("factions.command.tp.accept.fail.expired")) + .fail() + .send(target, false); + return 0; + } + + UUID requesterID = req.requesterID(); + ServerPlayer requester = + context.getSource().getServer().getPlayerList().getPlayer(requesterID); + if (requester == null) { + TeleportRequestManager.consume(targetID); + new Message(Component.translatable("factions.command.tp.fail.offline")) + .fail() + .send(target, false); + return 0; + } + + User targetUser = Command.getUser(target); + User requesterUser = Command.getUser(requester); + if (!targetUser.isInFaction() + || !requesterUser.isInFaction() + || !targetUser.getFaction().getID().equals(requesterUser.getFaction().getID())) { + TeleportRequestManager.consume(targetID); + new Message(Component.translatable("factions.command.tp.fail.not_member")) + .fail() + .send(target, false); + return 0; + } + + int cooldownSeconds = FactionsMod.CONFIG.TELEPORT.COOLDOWN_SECONDS; + if (TeleportRequestManager.isOnCooldown(requesterID, now, cooldownSeconds)) { + long remainingMs = + TeleportRequestManager.cooldownRemainingMillis( + requesterID, now, cooldownSeconds); + long remainingSeconds = (remainingMs + 999) / 1000; + Message cooldownMsg = + new Message( + Component.translatable( + "factions.command.tp.fail.cooldown", remainingSeconds)) + .fail(); + cooldownMsg.send(target, false); + cooldownMsg.send(requester, false); + return 0; + } + + int lastDamageTime = + ((CombatTrackerAccessor) requester.getCombatTracker()).getLastDamageTime(); + if (lastDamageTime != 0 + && requester.tickCount - lastDamageTime + <= FactionsMod.CONFIG.TELEPORT.DAMAGE_COOLDOWN) { + Message combatMsg = + new Message(Component.translatable("factions.command.tp.fail.combat")).fail(); + combatMsg.send(target, false); + combatMsg.send(requester, false); + return 0; + } + + TeleportRequestManager.consume(targetID); + TeleportRequestManager.markTeleported(requesterID, now); + + ServerLevel targetLevel = (ServerLevel) target.level(); + requester.teleportTo( + targetLevel, + target.getX(), + target.getY(), + target.getZ(), + new HashSet<>(), + target.getYHeadRot(), + target.getXRot(), + false); + + new Message( + Component.translatable( + "factions.command.tp.accept.success.requester", + target.getName().getString())) + .send(requester, false); + new Message( + Component.translatable( + "factions.command.tp.accept.success.target", + requester.getName().getString())) + .send(target, false); + return 1; + } + + private int deny(CommandContext context) throws CommandSyntaxException { + ServerPlayer target = context.getSource().getPlayerOrException(); + UUID targetID = target.getUUID(); + + Request req = TeleportRequestManager.peek(targetID); + if (req == null) { + new Message(Component.translatable("factions.command.tp.deny.fail.no_request")) + .fail() + .send(target, false); + return 0; + } + + TeleportRequestManager.deny(targetID); + + new Message(Component.translatable("factions.command.tp.deny.success")) + .send(target, false); + + ServerPlayer requester = + context.getSource().getServer().getPlayerList().getPlayer(req.requesterID()); + if (requester != null) { + new Message( + Component.translatable( + "factions.command.tp.deny.notified", + target.getName().getString())) + .fail() + .send(requester, false); + } + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("tp") + .requires( + Requires.multiple( + Requires.isMember(), + s -> + FactionsMod.CONFIG.TELEPORT != null + && FactionsMod.CONFIG.TELEPORT.ENABLED, + Requires.hasPerms("factions.tp", 0))) + .then(Commands.literal("accept").executes(this::accept)) + .then(Commands.literal("deny").executes(this::deny)) + .then( + Commands.argument("player", StringArgumentType.word()) + .suggests(Suggests.onlineFactionMembersButYou()) + .executes(this::request)) + .build(); + } +} diff --git a/src/main/java/io/icker/factions/command/TradeCommand.java b/src/main/java/io/icker/factions/command/TradeCommand.java new file mode 100644 index 00000000..545d33fb --- /dev/null +++ b/src/main/java/io/icker/factions/command/TradeCommand.java @@ -0,0 +1,215 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.FactionsMod; +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.User; +import io.icker.factions.config.TradeStageConfig; +import io.icker.factions.core.TradeRequestManager; +import io.icker.factions.core.TradeRequestManager.TradeRequest; +import io.icker.factions.core.TradeSession; +import io.icker.factions.ui.TradePlacementGui; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public class TradeCommand implements Command { + private int request(CommandContext context) throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + User sourceUser = Command.getUser(player); + + if (!sourceUser.isInFaction()) { + new Message(Component.translatable("factions.command.trade.fail.not_member")) + .fail() + .send(player, false); + return 0; + } + + Faction sourceFaction = sourceUser.getFaction(); + String factionName = StringArgumentType.getString(context, "faction"); + Faction targetFaction = Faction.getByName(factionName); + + if (targetFaction == null) { + new Message(Component.translatable("factions.command.trade.fail.nonexistent_faction")) + .fail() + .send(player, false); + return 0; + } + + if (targetFaction.getID().equals(sourceFaction.getID())) { + new Message(Component.translatable("factions.command.trade.fail.own_faction")) + .fail() + .send(player, false); + return 0; + } + + long now = System.currentTimeMillis(); + TradeRequestManager.request( + targetFaction.getID(), sourceFaction.getID(), player.getUUID(), now); + + new Message( + Component.translatable( + "factions.command.trade.request.received", + sourceFaction.getName(), + FactionsMod.CONFIG.GATHER.REQUEST_EXPIRY_SECONDS)) + .hover(Component.translatable("factions.command.trade.request.hover")) + .click("/factions trade y") + .send(targetFaction); + + new Message( + Component.translatable( + "factions.command.trade.request.sent", + targetFaction.getName())) + .send(player, false); + return 1; + } + + private int accept(CommandContext context) throws CommandSyntaxException { + ServerPlayer responder = context.getSource().getPlayerOrException(); + User responderUser = Command.getUser(responder); + + if (!responderUser.isInFaction()) { + new Message(Component.translatable("factions.command.trade.fail.not_member")) + .fail() + .send(responder, false); + return 0; + } + + Faction responderFaction = responderUser.getFaction(); + UUID responderFactionID = responderFaction.getID(); + + TradeRequest req = TradeRequestManager.peek(responderFactionID); + if (req == null) { + new Message(Component.translatable("factions.command.trade.accept.fail.no_request")) + .fail() + .send(responder, false); + return 0; + } + + long now = System.currentTimeMillis(); + int expirySeconds = FactionsMod.CONFIG.GATHER.REQUEST_EXPIRY_SECONDS; + if (TradeRequestManager.isExpired(req, now, expirySeconds)) { + TradeRequestManager.consume(responderFactionID); + new Message(Component.translatable("factions.command.trade.accept.fail.expired")) + .fail() + .send(responder, false); + return 0; + } + + TradeRequestManager.consume(responderFactionID); + + ServerPlayer requester = + context.getSource().getServer().getPlayerList().getPlayer(req.requesterPlayerID()); + + if (requester == null) { + new Message( + Component.translatable( + "factions.command.trade.accept.fail.requester_offline")) + .fail() + .send(responder, false); + return 0; + } + + TradeStageConfig cfg = FactionsMod.CONFIG.TRADE_STAGE; + if (cfg == null || WorldUtils.getWorld(cfg.LEVEL) == null) { + new Message(Component.translatable("factions.command.trade.stage.fail.no_world")) + .fail() + .send(responder, false); + return 0; + } + + if (TradeSession.getFor(requester.getUUID()).isPresent() + || TradeSession.getFor(responder.getUUID()).isPresent()) { + new Message(Component.translatable("factions.command.trade.stage.fail.already_trading")) + .fail() + .send(responder, false); + return 0; + } + + TradeSession session = TradeSession.begin(requester, responder, cfg); + if (session == null) { + new Message(Component.translatable("factions.command.trade.stage.fail.no_world")) + .fail() + .send(responder, false); + return 0; + } + + // TradePlacementGui's constructor calls this.open() automatically — do NOT call .open(). + new TradePlacementGui(requester, session.getId()); + new TradePlacementGui(responder, session.getId()); + + new Message(Component.translatable("factions.command.trade.accept.success")) + .send(responder, false); + new Message(Component.translatable("factions.command.trade.accept.success")) + .send(requester, false); + return 1; + } + + private int deny(CommandContext context) throws CommandSyntaxException { + ServerPlayer responder = context.getSource().getPlayerOrException(); + User responderUser = Command.getUser(responder); + + if (!responderUser.isInFaction()) { + new Message(Component.translatable("factions.command.trade.fail.not_member")) + .fail() + .send(responder, false); + return 0; + } + + Faction responderFaction = responderUser.getFaction(); + UUID responderFactionID = responderFaction.getID(); + + TradeRequest req = TradeRequestManager.peek(responderFactionID); + if (req == null) { + new Message(Component.translatable("factions.command.trade.deny.fail.no_request")) + .fail() + .send(responder, false); + return 0; + } + + TradeRequestManager.deny(responderFactionID); + + new Message(Component.translatable("factions.command.trade.deny.success")) + .send(responder, false); + + Faction requesterFaction = Faction.get(req.requesterFactionID()); + if (requesterFaction != null) { + new Message( + Component.translatable( + "factions.command.trade.deny.notified", + responderFaction.getName())) + .fail() + .send(requesterFaction); + } + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("trade") + .requires( + Requires.multiple( + Requires.isMember(), + s -> FactionsMod.CONFIG.TRADE_STAGE != null + && FactionsMod.CONFIG.TRADE_STAGE.ENABLED, + Requires.hasPerms("factions.trade", 0))) + .then(Commands.literal("y").executes(this::accept)) + .then(Commands.literal("n").executes(this::deny)) + .then( + Commands.argument("faction", StringArgumentType.greedyString()) + .suggests(Suggests.allFactions(false)) + .executes(this::request)) + .build(); + } +} diff --git a/src/main/java/io/icker/factions/command/XaeroSyncCommand.java b/src/main/java/io/icker/factions/command/XaeroSyncCommand.java new file mode 100644 index 00000000..eda406f5 --- /dev/null +++ b/src/main/java/io/icker/factions/command/XaeroSyncCommand.java @@ -0,0 +1,28 @@ +package io.icker.factions.command; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; + +import io.icker.factions.core.ClaimSyncSender; +import io.icker.factions.util.Command; +import io.icker.factions.util.Message; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +public class XaeroSyncCommand implements Command { + private int run(CommandContext context) throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + ClaimSyncSender.sendToPlayer(player); + new Message(Component.literal("Claim data re-synced to Xaero's map.")).send(player, false); + return 1; + } + + @Override + public LiteralCommandNode getNode() { + return Commands.literal("xaerosync").executes(this::run).build(); + } +} diff --git a/src/main/java/io/icker/factions/config/AnnouncerConfig.java b/src/main/java/io/icker/factions/config/AnnouncerConfig.java new file mode 100644 index 00000000..b3de9bdc --- /dev/null +++ b/src/main/java/io/icker/factions/config/AnnouncerConfig.java @@ -0,0 +1,11 @@ +package io.icker.factions.config; + +import com.google.gson.annotations.SerializedName; + +public class AnnouncerConfig { + @SerializedName("enabled") + public boolean ENABLED = true; + + @SerializedName("displaySeconds") + public int DISPLAY_SECONDS = 5; +} diff --git a/src/main/java/io/icker/factions/config/Config.java b/src/main/java/io/icker/factions/config/Config.java index df5076f2..334eb85a 100644 --- a/src/main/java/io/icker/factions/config/Config.java +++ b/src/main/java/io/icker/factions/config/Config.java @@ -40,6 +40,10 @@ public static Config load() { .registerTypeAdapter( PowerConfig.class, new Deserializer<>(PowerConfig.class)) .registerTypeAdapter(SafeConfig.class, new Deserializer<>(SafeConfig.class)) + .registerTypeAdapter( + TeleportConfig.class, new Deserializer<>(TeleportConfig.class)) + .registerTypeAdapter(GatherConfig.class, new Deserializer<>(GatherConfig.class)) + .registerTypeAdapter(TradeStageConfig.class, new Deserializer<>(TradeStageConfig.class)) .create(); try { @@ -83,6 +87,12 @@ public static Config load() { @SerializedName("power") public PowerConfig POWER = new PowerConfig(); + @SerializedName("guestGrant") + public GuestGrantConfig GUEST_GRANT = new GuestGrantConfig(); + + @SerializedName("announcer") + public AnnouncerConfig ANNOUNCER = new AnnouncerConfig(); + @SerializedName("safe") @Nullable public SafeConfig SAFE = new SafeConfig(); @@ -91,6 +101,17 @@ public static Config load() { @Nullable public HomeConfig HOME = new HomeConfig(); + @SerializedName("teleport") + public TeleportConfig TELEPORT = new TeleportConfig(); + + @SerializedName("gather") + @Nullable + public GatherConfig GATHER = new GatherConfig(); + + @SerializedName("tradeStage") + @Nullable + public TradeStageConfig TRADE_STAGE = new TradeStageConfig(); + @SerializedName("display") public DisplayConfig DISPLAY = new DisplayConfig(); diff --git a/src/main/java/io/icker/factions/config/GatherConfig.java b/src/main/java/io/icker/factions/config/GatherConfig.java new file mode 100644 index 00000000..eafed8ef --- /dev/null +++ b/src/main/java/io/icker/factions/config/GatherConfig.java @@ -0,0 +1,29 @@ +package io.icker.factions.config; + +import com.google.gson.annotations.SerializedName; + +public class GatherConfig { + @SerializedName("enabled") + public boolean ENABLED = true; + + @SerializedName("x") + public double X = -0.552; + + @SerializedName("y") + public double Y = 160.0; + + @SerializedName("z") + public double Z = -18.300; + + @SerializedName("yaw") + public float YAW = 179.6f; + + @SerializedName("pitch") + public float PITCH = 4.7f; + + @SerializedName("level") + public String LEVEL = "minecraft:overworld"; + + @SerializedName("requestExpirySeconds") + public int REQUEST_EXPIRY_SECONDS = 60; +} diff --git a/src/main/java/io/icker/factions/config/GuestGrantConfig.java b/src/main/java/io/icker/factions/config/GuestGrantConfig.java new file mode 100644 index 00000000..d10f08c2 --- /dev/null +++ b/src/main/java/io/icker/factions/config/GuestGrantConfig.java @@ -0,0 +1,22 @@ +package io.icker.factions.config; + +import com.google.gson.annotations.SerializedName; + +import java.util.ArrayList; +import java.util.List; + +public class GuestGrantConfig { + @SerializedName("maxBreak") + public int MAX_BREAK = 64; + + @SerializedName("maxPlace") + public int MAX_PLACE = 64; + + @SerializedName("restrictedItems") + public List RESTRICTED_ITEMS = + new ArrayList<>( + List.of( + "minecraft:flint_and_steel", + "minecraft:fire_charge", + "minecraft:lava_bucket")); +} diff --git a/src/main/java/io/icker/factions/config/SafeConfig.java b/src/main/java/io/icker/factions/config/SafeConfig.java index 2e7b9afc..7785d421 100644 --- a/src/main/java/io/icker/factions/config/SafeConfig.java +++ b/src/main/java/io/icker/factions/config/SafeConfig.java @@ -4,7 +4,7 @@ public class SafeConfig { @SerializedName("enderChest") - public boolean ENDER_CHEST = true; + public boolean ENDER_CHEST = false; @SerializedName("double") public boolean DOUBLE = true; diff --git a/src/main/java/io/icker/factions/config/TeleportConfig.java b/src/main/java/io/icker/factions/config/TeleportConfig.java new file mode 100644 index 00000000..8a5948ff --- /dev/null +++ b/src/main/java/io/icker/factions/config/TeleportConfig.java @@ -0,0 +1,17 @@ +package io.icker.factions.config; + +import com.google.gson.annotations.SerializedName; + +public class TeleportConfig { + @SerializedName("enabled") + public boolean ENABLED = true; + + @SerializedName("cooldownSeconds") + public int COOLDOWN_SECONDS = 15; + + @SerializedName("requestExpirySeconds") + public int REQUEST_EXPIRY_SECONDS = 60; + + @SerializedName("damageCooldown") + public int DAMAGE_COOLDOWN = 100; +} diff --git a/src/main/java/io/icker/factions/config/TradeStageConfig.java b/src/main/java/io/icker/factions/config/TradeStageConfig.java new file mode 100644 index 00000000..3b778896 --- /dev/null +++ b/src/main/java/io/icker/factions/config/TradeStageConfig.java @@ -0,0 +1,41 @@ +package io.icker.factions.config; + +import com.google.gson.annotations.SerializedName; + +public class TradeStageConfig { + @SerializedName("enabled") + public boolean ENABLED = true; + + @SerializedName("requesterX") + public double REQUESTER_X = 1.050; + + @SerializedName("requesterY") + public double REQUESTER_Y = 160.125; + + @SerializedName("requesterZ") + public double REQUESTER_Z = -27.500; + + @SerializedName("requesterYaw") + public float REQUESTER_YAW = 89.7f; + + @SerializedName("requesterPitch") + public float REQUESTER_PITCH = 3.2f; + + @SerializedName("recipientX") + public double RECIPIENT_X = -2.050; + + @SerializedName("recipientY") + public double RECIPIENT_Y = 160.125; + + @SerializedName("recipientZ") + public double RECIPIENT_Z = -27.482; + + @SerializedName("recipientYaw") + public float RECIPIENT_YAW = -90.9f; + + @SerializedName("recipientPitch") + public float RECIPIENT_PITCH = 3.2f; + + @SerializedName("level") + public String LEVEL = "minecraft:overworld"; +} diff --git a/src/main/java/io/icker/factions/core/AnnouncerManager.java b/src/main/java/io/icker/factions/core/AnnouncerManager.java new file mode 100644 index 00000000..e5542c94 --- /dev/null +++ b/src/main/java/io/icker/factions/core/AnnouncerManager.java @@ -0,0 +1,65 @@ +package io.icker.factions.core; + +import io.icker.factions.api.persistents.Faction; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; + +public class AnnouncerManager { + private static final int FADE_BASE_RGB = 0x222222; + private static final int WHITE_FALLBACK_RGB = 0xFFFFFF; + + private AnnouncerManager() {} + + public static Component buildAnnouncement(Faction faction) { + if (faction == null) { + return Component.translatableWithFallback( + "factions.announcer.wilderness", "Wilderness") + .withStyle(ChatFormatting.BOLD, ChatFormatting.WHITE); + } + + return Component.literal(faction.getName()) + .withStyle(ChatFormatting.BOLD, faction.getColor()); + } + + /** Returns a dimmed copy of the announcement for fade-in steps. step: 0 (darkest) .. steps-1 (full). */ + public static Component buildFadeStep(Faction faction, int step, int steps) { + int targetRgb = resolveTargetRgb(faction); + int interpolated = interpolateRgb(FADE_BASE_RGB, targetRgb, step, steps); + Style style = Style.EMPTY.withColor(TextColor.fromRgb(interpolated)).withBold(true); + + if (faction == null) { + return Component.translatableWithFallback( + "factions.announcer.wilderness", "Wilderness") + .setStyle(style); + } + + return Component.literal(faction.getName()).setStyle(style); + } + + private static int resolveTargetRgb(Faction faction) { + ChatFormatting formatting = faction == null ? ChatFormatting.WHITE : faction.getColor(); + TextColor color = TextColor.fromLegacyFormat(formatting); + return color != null ? color.getValue() : WHITE_FALLBACK_RGB; + } + + private static int interpolateRgb(int baseRgb, int targetRgb, int step, int steps) { + int clampedStep = Math.max(0, Math.min(step, steps - 1)); + float fraction = (clampedStep + 1) / (float) steps; + + int baseR = (baseRgb >> 16) & 0xFF; + int baseG = (baseRgb >> 8) & 0xFF; + int baseB = baseRgb & 0xFF; + + int targetR = (targetRgb >> 16) & 0xFF; + int targetG = (targetRgb >> 8) & 0xFF; + int targetB = targetRgb & 0xFF; + + int r = Math.round(baseR + (targetR - baseR) * fraction); + int g = Math.round(baseG + (targetG - baseG) * fraction); + int b = Math.round(baseB + (targetB - baseB) * fraction); + + return (r << 16) | (g << 8) | b; + } +} diff --git a/src/main/java/io/icker/factions/core/ClaimSyncSender.java b/src/main/java/io/icker/factions/core/ClaimSyncSender.java new file mode 100644 index 00000000..3c0eb550 --- /dev/null +++ b/src/main/java/io/icker/factions/core/ClaimSyncSender.java @@ -0,0 +1,73 @@ +package io.icker.factions.core; + +import io.icker.factions.api.events.ClaimEvents; +import io.icker.factions.api.events.FactionEvents; +import io.icker.factions.api.persistents.Claim; +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.net.ClaimSyncPayload; +import io.icker.factions.util.WorldUtils; + +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ClaimSyncSender { + private ClaimSyncSender() {} + + public static void register() { + ServerPlayConnectionEvents.JOIN.register( + (handler, sender, server) -> sendToPlayer(handler.getPlayer())); + + ClaimEvents.ADD.register(claim -> broadcastToAll()); + ClaimEvents.REMOVE.register((x, z, level, faction) -> broadcastToAll()); + + FactionEvents.MODIFY.register(faction -> broadcastToAll()); + FactionEvents.DISBAND.register(faction -> broadcastToAll()); + } + + public static void sendToPlayer(ServerPlayer player) { + ClaimSyncPayload payload = buildPayload(); + ServerPlayNetworking.send(player, payload); + } + + public static void broadcastToAll() { + var server = WorldUtils.getServer(); + if (server == null) return; + + ClaimSyncPayload payload = buildPayload(); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + ServerPlayNetworking.send(player, payload); + } + } + + private static ClaimSyncPayload buildPayload() { + List factionList = new ArrayList<>(); + for (Faction faction : Faction.all()) { + List claims = faction.getClaims(); + if (claims.isEmpty()) continue; + + Map> byDim = new LinkedHashMap<>(); + for (Claim claim : claims) { + byDim.computeIfAbsent(claim.level, ignored -> new ArrayList<>()) + .add(ChunkPos.pack(claim.x, claim.z)); + } + + List dims = new ArrayList<>(); + for (var entry : byDim.entrySet()) { + dims.add( + new ClaimSyncPayload.FactionClaims.DimClaims( + entry.getKey(), entry.getValue())); + } + factionList.add( + new ClaimSyncPayload.FactionClaims( + faction.getName(), faction.getColorValue(), dims)); + } + return new ClaimSyncPayload(factionList); + } +} diff --git a/src/main/java/io/icker/factions/core/FactionsManager.java b/src/main/java/io/icker/factions/core/FactionsManager.java index 94bdfe5b..ecf8f11c 100644 --- a/src/main/java/io/icker/factions/core/FactionsManager.java +++ b/src/main/java/io/icker/factions/core/FactionsManager.java @@ -15,7 +15,6 @@ import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.players.PlayerList; import net.minecraft.world.InteractionResult; @@ -52,9 +51,7 @@ public static void register() { BlockPos homePos = BlockPos.containing(home.x, home.y, home.z); - ServerLevel world = WorldUtils.getWorld(home.level); - - ChunkPos homeChunkPos = world.getChunk(homePos).getPos(); + ChunkPos homeChunkPos = WorldUtils.getChunkPos(homePos); if (homeChunkPos.x() == x && homeChunkPos.z() == z) { faction.setHome(null); @@ -126,14 +123,6 @@ private static InteractionResult openSafe(Player player, Faction faction) { User user = User.get(player.getUUID()); if (!user.isInFaction()) { - if (FactionsMod.CONFIG.SAFE != null && FactionsMod.CONFIG.SAFE.ENDER_CHEST) { - new Message( - Component.translatable( - "factions.events.no_enderchests_without_faction")) - .fail() - .send(player, false); - return InteractionResult.FAIL; - } return InteractionResult.PASS; } diff --git a/src/main/java/io/icker/factions/core/InteractionManager.java b/src/main/java/io/icker/factions/core/InteractionManager.java index ff36f10c..676e3d9b 100644 --- a/src/main/java/io/icker/factions/core/InteractionManager.java +++ b/src/main/java/io/icker/factions/core/InteractionManager.java @@ -1,9 +1,11 @@ package io.icker.factions.core; import io.icker.factions.FactionsMod; +import io.icker.factions.api.ClaimPermissions; import io.icker.factions.api.events.PlayerEvents; import io.icker.factions.api.persistents.Claim; import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.GuestGrant; import io.icker.factions.api.persistents.Relationship.Permissions; import io.icker.factions.api.persistents.User; import io.icker.factions.core.InteractionsUtil.InteractionsUtilActions; @@ -16,8 +18,6 @@ import net.fabricmc.fabric.api.event.player.UseItemCallback; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.server.permissions.Permission; -import net.minecraft.server.permissions.PermissionLevel; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.Entity; @@ -27,7 +27,6 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.context.UseOnContext; import net.minecraft.world.level.BlockGetter; -import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.Explosion; import net.minecraft.world.level.Level; @@ -57,7 +56,7 @@ public static void register() { private static boolean onBreakBlock( Level world, Player player, BlockPos pos, BlockState state, BlockEntity blockEntity) { boolean result = - checkPermissions(player, pos, world, Permissions.BREAK_BLOCKS) + ClaimPermissions.check(player, pos, world, Permissions.BREAK_BLOCKS, true) == InteractionResult.FAIL; if (result) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.BREAK_BLOCKS); @@ -67,32 +66,24 @@ private static boolean onBreakBlock( private static InteractionResult onExplodeBlock( Explosion explosion, BlockGetter world, BlockPos pos, BlockState state) { - if (explosion.getIndirectSourceEntity() != null - && explosion.getIndirectSourceEntity() instanceof Player) { + Entity source = explosion.getIndirectSourceEntity(); + if (source instanceof Player player) { InteractionResult result = - checkPermissions( - (Player) explosion.getIndirectSourceEntity(), - pos, - explosion.level(), - Permissions.BREAK_BLOCKS); + ClaimPermissions.check( + player, pos, explosion.level(), Permissions.BREAK_BLOCKS, true); if (result == InteractionResult.FAIL) { - InteractionsUtil.warn( - (ServerPlayer) explosion.getIndirectSourceEntity(), - InteractionsUtilActions.BREAK_BLOCKS); + InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.BREAK_BLOCKS); } return result; } else { if (!FactionsMod.CONFIG.BLOCK_TNT) return InteractionResult.PASS; - String dimension = explosion.level().dimension().identifier().toString(); - ChunkPos chunkPosition = explosion.level().getChunk(pos).getPos(); - - Claim claim = Claim.get(chunkPosition.x(), chunkPosition.z(), dimension); + Claim claim = Claim.get(explosion.level(), pos); if (claim == null) return InteractionResult.PASS; Faction claimFaction = claim.getFaction(); - if (claimFaction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT + if (claimFaction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT > claimFaction.getPower()) { return InteractionResult.PASS; } @@ -106,32 +97,27 @@ private static InteractionResult onExplodeBlock( } private static InteractionResult onExplodeDamage(Explosion explosion, Entity entity) { - if (explosion.getIndirectSourceEntity() != null - && explosion.getIndirectSourceEntity() instanceof Player) { + Entity source = explosion.getIndirectSourceEntity(); + if (source instanceof Player player) { InteractionResult result = - checkPermissions( - (Player) explosion.getIndirectSourceEntity(), + ClaimPermissions.check( + player, entity.blockPosition(), explosion.level(), Permissions.ATTACK_ENTITIES); if (result == InteractionResult.FAIL) { - InteractionsUtil.warn( - (ServerPlayer) explosion.getIndirectSourceEntity(), - InteractionsUtilActions.BREAK_BLOCKS); + InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.BREAK_BLOCKS); } return result; } else { if (!FactionsMod.CONFIG.BLOCK_TNT) return InteractionResult.PASS; - String dimension = explosion.level().dimension().identifier().toString(); - ChunkPos chunkPosition = explosion.level().getChunk(entity.blockPosition()).getPos(); - - Claim claim = Claim.get(chunkPosition.x(), chunkPosition.z(), dimension); + Claim claim = Claim.get(explosion.level(), entity.blockPosition()); if (claim == null) return InteractionResult.PASS; Faction claimFaction = claim.getFaction(); - if (claimFaction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT + if (claimFaction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT > claimFaction.getPower()) { return InteractionResult.PASS; } @@ -149,7 +135,15 @@ private static InteractionResult onUseBlock( ItemStack stack = player.getItemInHand(hand); BlockPos hitPos = hitResult.getBlockPos(); - if (checkPermissions(player, hitPos, world, Permissions.USE_BLOCKS) + + if (ClaimPermissions.isRestrictedForGuests(stack) + && ClaimPermissions.isNonMemberInClaim(player, hitPos, world)) { + InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.RESTRICTED_ITEM); + InteractionsUtil.sync((ServerPlayer) player, stack, hand); + return InteractionResult.FAIL; + } + + if (ClaimPermissions.check(player, hitPos, world, Permissions.USE_BLOCKS, false, stack) == InteractionResult.FAIL) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.USE_BLOCKS); InteractionsUtil.sync((ServerPlayer) player, stack, hand); @@ -157,7 +151,7 @@ private static InteractionResult onUseBlock( } BlockPos placePos = hitPos.offset(hitResult.getDirection().getUnitVec3i()); - if (checkPermissions(player, placePos, world, Permissions.USE_BLOCKS) + if (ClaimPermissions.check(player, placePos, world, Permissions.USE_BLOCKS, false, stack) == InteractionResult.FAIL) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.USE_BLOCKS); InteractionsUtil.sync((ServerPlayer) player, stack, hand); @@ -168,16 +162,29 @@ private static InteractionResult onUseBlock( } private static InteractionResult onPlaceBlock(UseOnContext context) { - if (checkPermissions( - context.getPlayer(), - context.getClickedPos(), + Player player = context.getPlayer(); + ItemStack stack = context.getItemInHand(); + BlockPos clickedPos = context.getClickedPos(); + + if (ClaimPermissions.isRestrictedForGuests(stack) + && ClaimPermissions.isNonMemberInClaim(player, clickedPos, context.getLevel())) { + InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.RESTRICTED_ITEM); + InteractionsUtil.sync((ServerPlayer) player, stack, context.getHand()); + return InteractionResult.FAIL; + } + + if (ClaimPermissions.check( + player, + clickedPos, context.getLevel(), - Permissions.PLACE_BLOCKS) + Permissions.PLACE_BLOCKS, + true, + stack) == InteractionResult.FAIL) { InteractionsUtil.warn( - (ServerPlayer) context.getPlayer(), InteractionsUtilActions.PLACE_BLOCKS); + (ServerPlayer) player, InteractionsUtilActions.PLACE_BLOCKS); InteractionsUtil.sync( - (ServerPlayer) context.getPlayer(), context.getItemInHand(), context.getHand()); + (ServerPlayer) player, stack, context.getHand()); return InteractionResult.FAIL; } @@ -185,16 +192,30 @@ private static InteractionResult onPlaceBlock(UseOnContext context) { } private static InteractionResult onUseBucket(Player player, Level world, InteractionHand hand) { - Item item = player.getItemInHand(hand).getItem(); + ItemStack stack = player.getItemInHand(hand); + Item item = stack.getItem(); if (item instanceof BucketItem) { + if (ClaimPermissions.isRestrictedForGuests(stack) + && ClaimPermissions.isNonMemberInClaim(player, player.blockPosition(), world)) { + InteractionsUtil.warn( + (ServerPlayer) player, InteractionsUtilActions.RESTRICTED_ITEM); + InteractionsUtil.sync((ServerPlayer) player, stack, hand); + return InteractionResult.FAIL; + } + InteractionResult playerResult = - checkPermissions( - player, player.blockPosition(), world, Permissions.PLACE_BLOCKS); + ClaimPermissions.check( + player, + player.blockPosition(), + world, + Permissions.PLACE_BLOCKS, + false, + stack); if (playerResult == InteractionResult.FAIL) { InteractionsUtil.warn( (ServerPlayer) player, InteractionsUtilActions.PLACE_OR_PICKUP_LIQUIDS); - InteractionsUtil.sync((ServerPlayer) player, player.getItemInHand(hand), hand); + InteractionsUtil.sync((ServerPlayer) player, stack, hand); return InteractionResult.FAIL; } @@ -207,20 +228,32 @@ private static InteractionResult onUseBucket(Player player, Level world, Interac if (raycastResult.getType() != BlockHitResult.Type.MISS) { BlockPos raycastPos = raycastResult.getBlockPos(); - if (checkPermissions(player, raycastPos, world, Permissions.PLACE_BLOCKS) + if (ClaimPermissions.check( + player, + raycastPos, + world, + Permissions.PLACE_BLOCKS, + false, + stack) == InteractionResult.FAIL) { InteractionsUtil.warn( (ServerPlayer) player, InteractionsUtilActions.PLACE_OR_PICKUP_LIQUIDS); - InteractionsUtil.sync((ServerPlayer) player, player.getItemInHand(hand), hand); + InteractionsUtil.sync((ServerPlayer) player, stack, hand); return InteractionResult.FAIL; } BlockPos placePos = raycastPos.offset(raycastResult.getDirection().getUnitVec3i()); - if (checkPermissions(player, placePos, world, Permissions.PLACE_BLOCKS) + if (ClaimPermissions.check( + player, + placePos, + world, + Permissions.PLACE_BLOCKS, + true, + stack) == InteractionResult.FAIL) { InteractionsUtil.warn( (ServerPlayer) player, InteractionsUtilActions.PLACE_OR_PICKUP_LIQUIDS); - InteractionsUtil.sync((ServerPlayer) player, player.getItemInHand(hand), hand); + InteractionsUtil.sync((ServerPlayer) player, stack, hand); return InteractionResult.FAIL; } } @@ -236,7 +269,7 @@ private static InteractionResult onAttackEntity( Entity entity, EntityHitResult hitResult) { if (entity != null - && checkPermissions( + && ClaimPermissions.check( player, entity.blockPosition(), world, Permissions.ATTACK_ENTITIES) == InteractionResult.FAIL) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.ATTACK_ENTITIES); @@ -259,7 +292,7 @@ private static InteractionResult onUseEntity( pos = entity.blockPosition(); } - if (checkPermissions(player, pos, level, Permissions.USE_ENTITIES) + if (ClaimPermissions.check(player, pos, level, Permissions.USE_ENTITIES) == InteractionResult.FAIL) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.USE_ENTITIES); return InteractionResult.FAIL; @@ -269,7 +302,7 @@ private static InteractionResult onUseEntity( } private static InteractionResult onUseInventory(Player player, BlockPos pos, Level world) { - if (checkPermissions(player, pos, world, Permissions.USE_INVENTORIES) + if (ClaimPermissions.check(player, pos, world, Permissions.USE_INVENTORIES) == InteractionResult.FAIL) { InteractionsUtil.warn((ServerPlayer) player, InteractionsUtilActions.USE_INVENTORY); return InteractionResult.FAIL; @@ -303,85 +336,4 @@ private static InteractionResult isInvulnerableTo(Entity source, Entity target) return InteractionResult.PASS; } - private static InteractionResult checkPermissions( - Player player, BlockPos position, Level world, Permissions permission) { - if (!FactionsMod.CONFIG.CLAIM_PROTECTION) { - return InteractionResult.PASS; - } - - User user = User.get(player.getUUID()); - if (player.permissions() - .hasPermission( - new Permission.HasCommandLevel( - PermissionLevel.byId( - FactionsMod.CONFIG.REQUIRED_BYPASS_LEVEL))) - && user.bypass) { - return InteractionResult.PASS; - } - - String dimension = world.dimension().identifier().toString(); - ChunkPos chunkPosition = world.getChunk(position).getPos(); - - Claim claim = Claim.get(chunkPosition.x(), chunkPosition.z(), dimension); - if (claim == null) return InteractionResult.PASS; - - Faction claimFaction = claim.getFaction(); - - if (claimFaction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT - > claimFaction.getPower()) { - return InteractionResult.PASS; - } - - if (!user.isInFaction()) { - return claimFaction.guest_permissions.contains(permission) - ? InteractionResult.SUCCESS - : InteractionResult.FAIL; - } - - Faction userFaction = user.getFaction(); - - if (claimFaction.equals(userFaction) - && (getRankLevel(claim.accessLevel) <= getRankLevel(user.rank) - || (user.rank == User.Rank.GUEST - && claimFaction.guest_permissions.contains(permission) - && claim.accessLevel == User.Rank.MEMBER))) { - return InteractionResult.SUCCESS; - } - - if (FactionsMod.CONFIG.RELATIONSHIPS.ALLY_OVERRIDES_PERMISSIONS - && claimFaction.isMutualAllies(userFaction.getID()) - && claim.accessLevel == User.Rank.MEMBER) { - return InteractionResult.SUCCESS; - } - - if (claimFaction.getRelationship(userFaction.getID()).permissions.contains(permission) - && claim.accessLevel == User.Rank.MEMBER) { - return InteractionResult.SUCCESS; - } - - return InteractionResult.FAIL; - } - - private static int getRankLevel(User.Rank rank) { - switch (rank) { - case OWNER -> { - return 3; - } - case LEADER -> { - return 2; - } - case COMMANDER -> { - return 1; - } - case MEMBER -> { - return 0; - } - case GUEST -> { - return -1; - } - default -> { - return -2; - } - } - } } diff --git a/src/main/java/io/icker/factions/core/InteractionsUtil.java b/src/main/java/io/icker/factions/core/InteractionsUtil.java index 07af5e3f..e54141b0 100644 --- a/src/main/java/io/icker/factions/core/InteractionsUtil.java +++ b/src/main/java/io/icker/factions/core/InteractionsUtil.java @@ -1,6 +1,5 @@ package io.icker.factions.core; -import io.icker.factions.api.persistents.User; import io.icker.factions.util.Message; import net.minecraft.network.chat.Component; @@ -23,7 +22,6 @@ public static void sync(ServerPlayer player, ItemStack itemStack, InteractionHan public static void warn(ServerPlayer player, InteractionsUtilActions action) { SoundManager.warningSound(player); - User user = User.get(player.getUUID()); new Message( Component.translatable( "factions.interactions.cannot_do", @@ -31,7 +29,7 @@ public static void warn(ServerPlayer player, InteractionsUtilActions action) { "factions.interactions.name." + action.toString().toLowerCase()))) .fail() - .send(player, !user.radar); + .send(player, true); } public enum InteractionsUtilActions { @@ -41,6 +39,7 @@ public enum InteractionsUtilActions { PLACE_OR_PICKUP_LIQUIDS, ATTACK_ENTITIES, USE_ENTITIES, - USE_INVENTORY + USE_INVENTORY, + RESTRICTED_ITEM } } diff --git a/src/main/java/io/icker/factions/core/ServerManager.java b/src/main/java/io/icker/factions/core/ServerManager.java index 1b9f0eb3..3d655bff 100644 --- a/src/main/java/io/icker/factions/core/ServerManager.java +++ b/src/main/java/io/icker/factions/core/ServerManager.java @@ -3,6 +3,7 @@ import io.icker.factions.api.events.MiscEvents; import io.icker.factions.api.persistents.Claim; import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.GuestGrant; import io.icker.factions.api.persistents.User; import io.icker.factions.util.Message; @@ -16,6 +17,7 @@ public class ServerManager { public static void register() { ServerPlayConnectionEvents.JOIN.register(ServerManager::playerJoin); + ServerPlayConnectionEvents.DISCONNECT.register(ServerManager::playerDisconnect); MiscEvents.ON_SAVE.register(ServerManager::save); } @@ -23,6 +25,14 @@ private static void save(MinecraftServer server) { Claim.save(); Faction.save(); User.save(); + GuestGrant.save(); + } + + private static void playerDisconnect( + ServerGamePacketListenerImpl handler, MinecraftServer server) { + WorldManager.clearPlayerState(handler.getPlayer().getUUID()); + TeleportRequestManager.clearPlayer(handler.getPlayer().getUUID()); + GuestGrant.save(); } private static void playerJoin( diff --git a/src/main/java/io/icker/factions/core/TeleportRequestManager.java b/src/main/java/io/icker/factions/core/TeleportRequestManager.java new file mode 100644 index 00000000..769e2b6c --- /dev/null +++ b/src/main/java/io/icker/factions/core/TeleportRequestManager.java @@ -0,0 +1,88 @@ +package io.icker.factions.core; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory state manager for /factions tp requests and per-player teleport cooldowns. + * + *

Not persisted: pending requests and cooldowns are ephemeral by design and are dropped on + * server restart or player disconnect. + * + *

Time-sensitive methods accept {@code nowMillis} as a parameter rather than calling {@link + * System#currentTimeMillis()} internally so expiry and cooldown behaviour can be unit-tested + * without sleeping. + */ +public final class TeleportRequestManager { + /** A single pending teleport request from {@code requesterID} to some target. */ + public record Request(UUID requesterID, long createdAtMillis) {} + + /** target uuid -> pending request (one slot per target; newer overwrites older). */ + private static final Map pending = new ConcurrentHashMap<>(); + + /** player uuid -> millis of last successful teleport. */ + private static final Map cooldown = new ConcurrentHashMap<>(); + + private TeleportRequestManager() {} + + /** Store a pending request keyed by target; overwrites any prior request for that target. */ + public static void request(UUID targetID, UUID requesterID, long nowMillis) { + pending.put(targetID, new Request(requesterID, nowMillis)); + } + + /** Return the pending request for {@code targetID}, or {@code null} if none. */ + public static Request peek(UUID targetID) { + return pending.get(targetID); + } + + /** Pure predicate: has {@code request} exceeded the expiry window at {@code nowMillis}? */ + public static boolean isExpired(Request request, long nowMillis, int expirySeconds) { + return nowMillis - request.createdAtMillis() >= expirySeconds * 1000L; + } + + /** Return and clear the pending request for {@code targetID}; {@code null} if none. */ + public static Request consume(UUID targetID) { + return pending.remove(targetID); + } + + /** Remove any pending request for {@code targetID}. */ + public static void deny(UUID targetID) { + pending.remove(targetID); + } + + /** True if {@code playerID} teleported inside the cooldown window ending at {@code nowMillis}. */ + public static boolean isOnCooldown(UUID playerID, long nowMillis, int cooldownSeconds) { + Long last = cooldown.get(playerID); + if (last == null) return false; + return nowMillis - last < cooldownSeconds * 1000L; + } + + /** Milliseconds of cooldown remaining for {@code playerID}, clamped to 0. */ + public static long cooldownRemainingMillis(UUID playerID, long nowMillis, int cooldownSeconds) { + Long last = cooldown.get(playerID); + if (last == null) return 0L; + long remaining = (last + cooldownSeconds * 1000L) - nowMillis; + return Math.max(0L, remaining); + } + + /** Record that {@code playerID} completed a teleport at {@code nowMillis}. */ + public static void markTeleported(UUID playerID, long nowMillis) { + cooldown.put(playerID, nowMillis); + } + + /** + * Drop all state associated with a disconnecting player: + * + *

    + *
  • any pending request where they are the target, + *
  • any pending request where they are the requester (otherwise the map leaks), + *
  • their cooldown entry. + *
+ */ + public static void clearPlayer(UUID playerID) { + pending.remove(playerID); + pending.entrySet().removeIf(e -> e.getValue().requesterID().equals(playerID)); + cooldown.remove(playerID); + } +} diff --git a/src/main/java/io/icker/factions/core/TradeRequestManager.java b/src/main/java/io/icker/factions/core/TradeRequestManager.java new file mode 100644 index 00000000..7ff53bb4 --- /dev/null +++ b/src/main/java/io/icker/factions/core/TradeRequestManager.java @@ -0,0 +1,68 @@ +package io.icker.factions.core; + +import io.icker.factions.api.events.FactionEvents; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory state manager for /f trade requests between factions. + * + *

Not persisted: pending requests are ephemeral by design and are dropped on server restart. + * + *

Time-sensitive methods accept {@code nowMillis} as a parameter rather than calling {@link + * System#currentTimeMillis()} internally so expiry behaviour can be unit-tested without sleeping. + */ +public final class TradeRequestManager { + /** A single pending trade request from {@code requesterFactionID} (initiated by {@code requesterPlayerID}) to some target faction. */ + public record TradeRequest(UUID requesterFactionID, UUID requesterPlayerID, long createdAtMillis) {} + + /** target faction uuid -> pending trade request (one slot per target faction; newer overwrites older). */ + private static final Map pending = new ConcurrentHashMap<>(); + + private TradeRequestManager() {} + + /** Register event listeners so pending state is cleared when a faction disbands. */ + public static void register() { + FactionEvents.DISBAND.register(faction -> clearFactionRequests(faction.getID())); + } + + /** Store a pending trade request keyed by target faction; overwrites any prior request for that target. */ + public static void request(UUID targetFactionID, UUID requesterFactionID, UUID requesterPlayerID, long nowMillis) { + pending.put(targetFactionID, new TradeRequest(requesterFactionID, requesterPlayerID, nowMillis)); + } + + /** Return the pending trade request for {@code targetFactionID}, or {@code null} if none. */ + public static TradeRequest peek(UUID targetFactionID) { + return pending.get(targetFactionID); + } + + /** Return and clear the pending trade request for {@code targetFactionID}; {@code null} if none. */ + public static TradeRequest consume(UUID targetFactionID) { + return pending.remove(targetFactionID); + } + + /** Remove any pending trade request for {@code targetFactionID}. */ + public static void deny(UUID targetFactionID) { + pending.remove(targetFactionID); + } + + /** Pure predicate: has {@code req} exceeded the expiry window at {@code nowMillis}? */ + public static boolean isExpired(TradeRequest req, long nowMillis, int expirySeconds) { + return nowMillis - req.createdAtMillis() >= expirySeconds * 1000L; + } + + /** + * Drop all state associated with a disbanding faction: + * + *

    + *
  • any pending request where they are the target faction, + *
  • any pending request where they are the requester faction (otherwise the map leaks). + *
+ */ + public static void clearFactionRequests(UUID factionID) { + pending.remove(factionID); + pending.entrySet().removeIf(e -> e.getValue().requesterFactionID().equals(factionID)); + } +} diff --git a/src/main/java/io/icker/factions/core/TradeSession.java b/src/main/java/io/icker/factions/core/TradeSession.java new file mode 100644 index 00000000..79263f91 --- /dev/null +++ b/src/main/java/io/icker/factions/core/TradeSession.java @@ -0,0 +1,688 @@ +package io.icker.factions.core; + +import eu.pb4.sgui.api.gui.SimpleGui; + +import io.icker.factions.api.events.FactionEvents; +import io.icker.factions.api.persistents.Faction; +import io.icker.factions.api.persistents.User; +import io.icker.factions.config.TradeStageConfig; +import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; + +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory state machine and registry for the two-phase /f trade GUI system. + * + *

Not persisted: active sessions are ephemeral and are cancelled on server shutdown via {@link + * #clearAllForShutdown()}. + * + *

Task 3 defines the API skeleton. {@link #commit(UUID)} body is filled in by Task 10; event + * listener registrations in {@link #register()} are filled in by Task 11. + */ +public final class TradeSession { + /** Lifecycle phase. Transitions are one-way except {@code REVEAL -> PLACEMENT} via renegotiate. */ + public enum Phase { + PLACEMENT, + REVEAL, + SWAPPING, + CANCELLED, + COMPLETED + } + + /** Reason a session was cancelled — used to route item-return logic and actionbar message. */ + public enum Cause { + PLAYER_CANCELLED, + DECLINED, + INVENTORY_FULL, + DISCONNECTED, + GUI_CLOSED, + FACTION_DISBANDED, + SERVER_STOPPING + } + + /** Snapshot of a player's location captured at {@link #begin} for return-teleport on cancel. */ + public record PreviousPosition( + String levelResource, double x, double y, double z, float yaw, float pitch) {} + + /** Seam: teleport a player by UUID. Real impl looks up ServerPlayer + ServerLevel and calls teleportTo. */ + @FunctionalInterface + public interface TeleportAction { + void teleport( + UUID playerId, + String levelResource, + double x, + double y, + double z, + float yaw, + float pitch); + } + + /** Seam: open the phase-appropriate GUI for a player. Real impl is provided by the GUI layer. */ + @FunctionalInterface + public interface GuiOpenAction { + void open(UUID playerId, Phase phase); + } + + /** + * Seam: return items from a placement container to a player's inventory, dropping overflow at + * the player's previous position. If {@code forceDropAll} is true (disconnecting player), drop + * every stack instead of returning any. Clears the container after processing. + */ + @FunctionalInterface + public interface InventoryAdapter { + void returnOrDrop( + SimpleContainer container, + UUID playerId, + PreviousPosition prev, + boolean forceDropAll); + } + + /** Default teleport: look up the online player + world and invoke real MC teleportTo. */ + static TeleportAction teleportImpl = + (id, lvl, x, y, z, yaw, pitch) -> { + MinecraftServer server = WorldUtils.server; + if (server == null) return; + ServerPlayer player = server.getPlayerList().getPlayer(id); + if (player == null) return; + ServerLevel world = WorldUtils.getWorld(lvl); + if (world == null) return; + player.teleportTo(world, x, y, z, new HashSet<>(), yaw, pitch, false); + }; + + /** Default GUI-open: no-op. The GUI layer (Task 5/6) wires this to open the correct screen. */ + static GuiOpenAction guiOpenImpl = (id, phase) -> { + /* caller opens the GUI directly — no default behavior */ + }; + + /** Default inventory: online player gets return-with-overflow-drop; offline gets full drop. */ + static InventoryAdapter inventoryImpl = + (container, playerId, prev, forceDropAll) -> { + MinecraftServer server = WorldUtils.server; + ServerPlayer player = + server == null ? null : server.getPlayerList().getPlayer(playerId); + if (forceDropAll || player == null) { + dropItems(container, prev); + } else { + returnItems(container, player, prev); + } + }; + + private static final Map SESSIONS = new ConcurrentHashMap<>(); + private static final Map BY_PLAYER = new ConcurrentHashMap<>(); + + final UUID sessionId; + final UUID requesterId; + final UUID recipientId; + final PreviousPosition requesterPrev; + final PreviousPosition recipientPrev; + volatile Phase phase = Phase.PLACEMENT; + final SimpleContainer requesterPlacement = new SimpleContainer(27); + final SimpleContainer recipientPlacement = new SimpleContainer(27); + volatile boolean requesterConfirmed = false; + volatile boolean recipientConfirmed = false; + volatile boolean requesterAccepted = false; + volatile boolean recipientAccepted = false; + SimpleGui requesterGui = null; + SimpleGui recipientGui = null; + + private TradeSession( + UUID sessionId, + UUID requesterId, + UUID recipientId, + PreviousPosition requesterPrev, + PreviousPosition recipientPrev) { + this.sessionId = sessionId; + this.requesterId = requesterId; + this.recipientId = recipientId; + this.requesterPrev = requesterPrev; + this.recipientPrev = recipientPrev; + } + + // ---------------------------------------------------------------------- instance accessors + + public UUID getId() { + return sessionId; + } + + public UUID getRequesterId() { + return requesterId; + } + + public UUID getRecipientId() { + return recipientId; + } + + public Phase getPhase() { + return phase; + } + + public SimpleContainer getRequesterPlacement() { + return requesterPlacement; + } + + public SimpleContainer getRecipientPlacement() { + return recipientPlacement; + } + + public SimpleContainer ownPlacementFor(ServerPlayer player) { + return player.getUUID().equals(requesterId) ? requesterPlacement : recipientPlacement; + } + + public SimpleContainer otherPlacementFor(ServerPlayer player) { + return player.getUUID().equals(requesterId) ? recipientPlacement : requesterPlacement; + } + + public PreviousPosition prevPositionFor(UUID playerId) { + return playerId.equals(requesterId) ? requesterPrev : recipientPrev; + } + + /** + * Register the given player's active GUI so {@link #cancel} can force-close it. Called by the + * GUI layer during construction; a null-safe no-op if {@code playerId} is not part of this + * session. + */ + public void setGuiFor(UUID playerId, SimpleGui gui) { + if (playerId.equals(requesterId)) { + this.requesterGui = gui; + } else if (playerId.equals(recipientId)) { + this.recipientGui = gui; + } + } + + // ---------------------------------------------------------------------- static API + + /** + * Begin a new session: validates neither player is already in one, captures pre-positions, + * teleports both to their staging spots, and registers the session. + * + * @return the new session, or {@code null} if validation fails or the stage world is unavailable + */ + public static TradeSession begin( + ServerPlayer requester, ServerPlayer recipient, TradeStageConfig cfg) { + if (BY_PLAYER.containsKey(requester.getUUID()) + || BY_PLAYER.containsKey(recipient.getUUID())) { + return null; + } + + ServerLevel stageWorld = WorldUtils.getWorld(cfg.LEVEL); + if (stageWorld == null) return null; + + PreviousPosition requesterPrev = capturePreviousPosition(requester); + PreviousPosition recipientPrev = capturePreviousPosition(recipient); + + teleportImpl.teleport( + requester.getUUID(), + cfg.LEVEL, + cfg.REQUESTER_X, + cfg.REQUESTER_Y, + cfg.REQUESTER_Z, + cfg.REQUESTER_YAW, + cfg.REQUESTER_PITCH); + teleportImpl.teleport( + recipient.getUUID(), + cfg.LEVEL, + cfg.RECIPIENT_X, + cfg.RECIPIENT_Y, + cfg.RECIPIENT_Z, + cfg.RECIPIENT_YAW, + cfg.RECIPIENT_PITCH); + + UUID id = UUID.randomUUID(); + TradeSession session = + new TradeSession( + id, requester.getUUID(), recipient.getUUID(), requesterPrev, recipientPrev); + SESSIONS.put(id, session); + BY_PLAYER.put(requester.getUUID(), id); + BY_PLAYER.put(recipient.getUUID(), id); + return session; + } + + /** + * Flip the per-player Confirm flag; if both are set, advance to {@link Phase#REVEAL}. Does NOT + * open the reveal GUI — the caller polls or subscribes. + */ + public static void confirmPlacement(UUID sessionId, UUID playerId) { + TradeSession session = SESSIONS.get(sessionId); + if (session == null || session.phase != Phase.PLACEMENT) return; + + if (playerId.equals(session.requesterId)) { + session.requesterConfirmed = true; + } else if (playerId.equals(session.recipientId)) { + session.recipientConfirmed = true; + } + + if (session.requesterConfirmed && session.recipientConfirmed) { + session.phase = Phase.REVEAL; + } + } + + /** + * Flip the per-player Accept flag; if both are set, advance to {@link Phase#SWAPPING} and call + * {@link #commit(UUID)}. + */ + public static void acceptReveal(UUID sessionId, UUID playerId) { + TradeSession session = SESSIONS.get(sessionId); + if (session == null || session.phase != Phase.REVEAL) return; + + if (playerId.equals(session.requesterId)) { + session.requesterAccepted = true; + } else if (playerId.equals(session.recipientId)) { + session.recipientAccepted = true; + } + + if (session.requesterAccepted && session.recipientAccepted) { + session.phase = Phase.SWAPPING; + commit(sessionId); + } + } + + /** + * Return to Placement without clearing item contents. Both confirm and accept flags are + * cleared; items in Placement containers stay untouched. + */ + public static void renegotiate(UUID sessionId) { + TradeSession session = SESSIONS.get(sessionId); + if (session == null) return; + session.requesterConfirmed = false; + session.recipientConfirmed = false; + session.requesterAccepted = false; + session.recipientAccepted = false; + session.phase = Phase.PLACEMENT; + } + + /** + * Idempotently cancel a session: flip phase to CANCELLED, return items (or drop on disconnect), + * teleport online players back, close GUIs, notify players, and remove the session from the + * registry. All mutations run on the server main thread. + */ + public static void cancel(UUID sessionId, Cause cause) { + TradeSession session = SESSIONS.get(sessionId); + if (session == null) return; + + // SWAPPING is terminal-in-progress: it blocks external cancels racing commit's + // scheduled runnable. Internal failure paths inside performSwapWork bypass + // this guard via cancelFromSwap(). + synchronized (session) { + if (session.phase == Phase.CANCELLED + || session.phase == Phase.COMPLETED + || session.phase == Phase.SWAPPING) return; + session.phase = Phase.CANCELLED; + } + + runOnMainThread(() -> performCancelWork(session, cause)); + } + + private static void performCancelWork(TradeSession session, Cause cause) { + MinecraftServer server = WorldUtils.server; + ServerPlayer requester = + server == null ? null : server.getPlayerList().getPlayer(session.requesterId); + ServerPlayer recipient = + server == null ? null : server.getPlayerList().getPlayer(session.recipientId); + + // Item return: DISCONNECTED drops the offline player's items; others attempt inventory add. + boolean requesterDropAll = cause == Cause.DISCONNECTED && requester == null; + boolean recipientDropAll = cause == Cause.DISCONNECTED && recipient == null; + inventoryImpl.returnOrDrop( + session.requesterPlacement, + session.requesterId, + session.requesterPrev, + requesterDropAll); + inventoryImpl.returnOrDrop( + session.recipientPlacement, + session.recipientId, + session.recipientPrev, + recipientDropAll); + + // Teleport online players back to their pre-session position. + if (requester != null) { + teleportImpl.teleport( + session.requesterId, + session.requesterPrev.levelResource(), + session.requesterPrev.x(), + session.requesterPrev.y(), + session.requesterPrev.z(), + session.requesterPrev.yaw(), + session.requesterPrev.pitch()); + } + if (recipient != null) { + teleportImpl.teleport( + session.recipientId, + session.recipientPrev.levelResource(), + session.recipientPrev.x(), + session.recipientPrev.y(), + session.recipientPrev.z(), + session.recipientPrev.yaw(), + session.recipientPrev.pitch()); + } + + // Close any open GUIs. + if (session.requesterGui != null) session.requesterGui.close(); + if (session.recipientGui != null) session.recipientGui.close(); + + // Notify online players via actionbar. + Component msg = Component.translatable(actionbarKey(cause)); + if (requester != null) new Message(msg.copy()).send(requester, true); + if (recipient != null) new Message(msg.copy()).send(recipient, true); + + // Deregister. + SESSIONS.remove(session.sessionId); + BY_PLAYER.remove(session.requesterId); + BY_PLAYER.remove(session.recipientId); + } + + /** + * Atomic two-player inventory swap. Runs the pre-flight capacity check and the + * actual swap inside a single {@code server.execute()} runnable so both fire on + * the same server tick — this eliminates TOCTOU between "we simulated it would + * fit" and "we mutated". Failure of ANY pre-flight check aborts before touching + * either placement container, keeping the swap all-or-nothing. + */ + public static void commit(UUID sessionId) { + TradeSession session = SESSIONS.get(sessionId); + if (session == null + || (session.phase != Phase.REVEAL && session.phase != Phase.SWAPPING)) return; + + // Phase must flip BEFORE scheduling: any external cancel arriving between + // this line and performSwapWork will hit the SWAPPING guard in cancel() and + // no-op, giving the runnable exclusive ownership of the session state. + session.phase = Phase.SWAPPING; + + runOnMainThread(() -> performSwapWork(session)); + } + + private static void performSwapWork(TradeSession session) { + MinecraftServer server = WorldUtils.server; + if (server == null) { + cancelFromSwap(session, Cause.SERVER_STOPPING); + return; + } + ServerPlayer requester = server.getPlayerList().getPlayer(session.requesterId); + ServerPlayer recipient = server.getPlayerList().getPlayer(session.recipientId); + if (requester == null || recipient == null) { + cancelFromSwap(session, Cause.DISCONNECTED); + return; + } + + // Pre-flight: simulate delivering each side's payload into the other side's + // inventory. If either simulation overflows, abort before mutating anything — + // items remain in their placement containers and cancel() returns them. + if (!canAcceptAll(requester.getInventory(), session.recipientPlacement) + || !canAcceptAll(recipient.getInventory(), session.requesterPlacement)) { + cancelFromSwap(session, Cause.INVENTORY_FULL); + return; + } + + for (int i = 0; i < session.recipientPlacement.getContainerSize(); i++) { + ItemStack stack = session.recipientPlacement.removeItemNoUpdate(i); + if (!stack.isEmpty()) { + requester.getInventory().add(stack); + } + } + for (int i = 0; i < session.requesterPlacement.getContainerSize(); i++) { + ItemStack stack = session.requesterPlacement.removeItemNoUpdate(i); + if (!stack.isEmpty()) { + recipient.getInventory().add(stack); + } + } + + teleportImpl.teleport( + session.requesterId, + session.requesterPrev.levelResource(), + session.requesterPrev.x(), + session.requesterPrev.y(), + session.requesterPrev.z(), + session.requesterPrev.yaw(), + session.requesterPrev.pitch()); + teleportImpl.teleport( + session.recipientId, + session.recipientPrev.levelResource(), + session.recipientPrev.x(), + session.recipientPrev.y(), + session.recipientPrev.z(), + session.recipientPrev.yaw(), + session.recipientPrev.pitch()); + + Component completed = Component.translatable("factions.trade.result.completed"); + new Message(completed.copy()).send(requester, true); + new Message(completed.copy()).send(recipient, true); + + SimpleGui rGui = session.requesterGui; + SimpleGui recGui = session.recipientGui; + if (rGui != null) rGui.close(); + if (recGui != null) recGui.close(); + + SESSIONS.remove(session.sessionId); + BY_PLAYER.remove(session.requesterId); + BY_PLAYER.remove(session.recipientId); + session.phase = Phase.COMPLETED; + } + + /** + * Cancel-equivalent cleanup invoked from inside performSwapWork's failure paths. + * cancel() would treat SWAPPING as terminal-in-progress and no-op, so we flip to + * CANCELLED and dispatch performCancelWork directly on the tick we already own. + */ + private static void cancelFromSwap(TradeSession session, Cause cause) { + session.phase = Phase.CANCELLED; + performCancelWork(session, cause); + } + + /** Return the active session containing {@code playerId}, if any. */ + public static Optional getFor(UUID playerId) { + UUID sessionId = BY_PLAYER.get(playerId); + if (sessionId == null) return Optional.empty(); + return Optional.ofNullable(SESSIONS.get(sessionId)); + } + + /** Defensive copy of currently-active sessions — safe to iterate while sessions are cancelled. */ + public static Collection snapshotAllSessions() { + return new ArrayList<>(SESSIONS.values()); + } + + /** Cancel every active session with {@link Cause#SERVER_STOPPING}. */ + public static void clearAllForShutdown() { + for (TradeSession s : snapshotAllSessions()) { + cancel(s.sessionId, Cause.SERVER_STOPPING); + } + } + + /** Task 11 fills in the actual event-listener registrations (disconnect, disband, shutdown). */ + public static void register() { + ServerPlayConnectionEvents.DISCONNECT.register( + (handler, server) -> { + UUID playerId = handler.player.getUUID(); + TradeSession.getFor(playerId) + .ifPresent( + session -> + TradeSession.cancel(session.getId(), Cause.DISCONNECTED)); + }); + + FactionEvents.DISBAND.register( + faction -> { + UUID disbandedId = faction.getID(); + for (TradeSession session : snapshotAllSessions()) { + User reqUser = User.get(session.requesterId); + User recUser = User.get(session.recipientId); + Faction reqFaction = reqUser != null ? reqUser.getFaction() : null; + Faction recFaction = recUser != null ? recUser.getFaction() : null; + UUID reqFId = reqFaction != null ? reqFaction.getID() : null; + UUID recFId = recFaction != null ? recFaction.getID() : null; + if (disbandedId.equals(reqFId) || disbandedId.equals(recFId)) { + cancel(session.sessionId, Cause.FACTION_DISBANDED); + } + } + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> clearAllForShutdown()); + } + + // ---------------------------------------------------------------------- helpers + + private static PreviousPosition capturePreviousPosition(ServerPlayer player) { + var pos = player.position(); + return new PreviousPosition( + WorldUtils.dimensionString(player.level()), + pos.x, + pos.y, + pos.z, + player.getYRot(), + player.getXRot()); + } + + private static void runOnMainThread(Runnable task) { + MinecraftServer server = WorldUtils.server; + if (server != null) { + server.execute(task); + } else { + task.run(); + } + } + + private static String actionbarKey(Cause cause) { + return switch (cause) { + case PLAYER_CANCELLED -> "factions.trade.cancelled.player_cancelled"; + case DECLINED -> "factions.trade.cancelled.declined"; + case INVENTORY_FULL -> "factions.trade.cancelled.inventory_full"; + case DISCONNECTED -> "factions.trade.cancelled.disconnected"; + case GUI_CLOSED -> "factions.trade.cancelled.gui_closed"; + case FACTION_DISBANDED -> "factions.trade.cancelled.faction_disbanded"; + case SERVER_STOPPING -> "factions.trade.cancelled.server_stopping"; + }; + } + + /** + * For each occupied slot: copy the stack, attempt to add to the player's inventory, drop any + * remainder at {@code prev}, and clear the slot. The Inventory#add call mutates the passed copy + * — remaining count > 0 means overflow. + */ + private static void returnItems( + SimpleContainer container, ServerPlayer player, PreviousPosition prev) { + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (stack.isEmpty()) continue; + ItemStack copy = stack.copy(); + player.getInventory().add(copy); + if (copy.getCount() > 0) { + dropStackAt(copy, prev); + } + container.setItem(i, ItemStack.EMPTY); + } + } + + /** For each occupied slot: drop the stack at {@code prev} as an ItemEntity and clear the slot. */ + private static void dropItems(SimpleContainer container, PreviousPosition prev) { + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack stack = container.getItem(i); + if (stack.isEmpty()) continue; + dropStackAt(stack.copy(), prev); + container.setItem(i, ItemStack.EMPTY); + } + } + + private static void dropStackAt(ItemStack stack, PreviousPosition prev) { + if (stack.isEmpty()) return; + ServerLevel world = WorldUtils.getWorld(prev.levelResource()); + if (world == null) return; + ItemEntity entity = new ItemEntity(world, prev.x(), prev.y(), prev.z(), stack); + world.addFreshEntity(entity); + } + + /** + * Non-mutating pre-flight: snapshots the player's inventory into a scratch + * container and simulates delivering every stack from {@code incoming}. Returns + * false as soon as one stack does not fit — the real swap is aborted before any + * mutation occurs. + */ + private static boolean canAcceptAll( + net.minecraft.world.entity.player.Inventory playerInv, SimpleContainer incoming) { + SimpleContainer snapshot = new SimpleContainer(playerInv.getContainerSize()); + for (int i = 0; i < playerInv.getContainerSize(); i++) { + snapshot.setItem(i, playerInv.getItem(i).copy()); + } + return canFitAll(snapshot, incoming); + } + + /** + * Package-private capacity check: simulates delivering every stack from + * {@code incoming} into {@code snapshot} and returns false at the first + * overflow. {@code snapshot} MAY be mutated (the caller must own the copy). + * Exposed for direct testing of the swap invariant without a live + * {@link net.minecraft.world.entity.player.Inventory}. + */ + static boolean canFitAll(SimpleContainer snapshot, SimpleContainer incoming) { + for (int i = 0; i < incoming.getContainerSize(); i++) { + ItemStack stack = incoming.getItem(i); + if (stack.isEmpty()) continue; + ItemStack remainder = addToContainer(snapshot, stack.copy()); + if (!remainder.isEmpty()) return false; + } + return true; + } + + /** + * Vanilla-parity add: first fill partial stacks of the same item+components, then + * fall back to an empty slot. Mutates {@code stack} in place; returns the + * un-placed remainder (empty when everything fit). Uses + * {@link ItemStack#isSameItemSameComponents} because MC 26.2 replaced the + * pre-component tag comparator. Package-private for direct testing. + */ + static ItemStack addToContainer(SimpleContainer c, ItemStack stack) { + for (int i = 0; i < c.getContainerSize(); i++) { + ItemStack existing = c.getItem(i); + if (!existing.isEmpty() + && ItemStack.isSameItemSameComponents(existing, stack) + && existing.getCount() < existing.getMaxStackSize()) { + int space = existing.getMaxStackSize() - existing.getCount(); + int amount = Math.min(space, stack.getCount()); + existing.grow(amount); + stack.shrink(amount); + if (stack.isEmpty()) return ItemStack.EMPTY; + } + } + for (int i = 0; i < c.getContainerSize(); i++) { + if (c.getItem(i).isEmpty()) { + c.setItem(i, stack.copy()); + return ItemStack.EMPTY; + } + } + return stack; + } + + // ---------------------------------------------------------------------- test-only helpers + + /** + * Testing only: create and register a session with dummy positions. Bypasses + * {@link #begin(ServerPlayer, ServerPlayer, TradeStageConfig)}, which requires + * live {@link ServerPlayer} instances that are not available in unit tests. + * Callers MUST reset {@link #teleportImpl}, {@link #guiOpenImpl}, and + * {@link #inventoryImpl} to no-op stubs and clean up the registered session in + * teardown. + */ + static TradeSession beginForTest(UUID requesterId, UUID recipientId) { + PreviousPosition dummy = new PreviousPosition("minecraft:overworld", 0, 0, 0, 0, 0); + UUID id = UUID.randomUUID(); + TradeSession session = + new TradeSession(id, requesterId, recipientId, dummy, dummy); + SESSIONS.put(id, session); + BY_PLAYER.put(requesterId, id); + BY_PLAYER.put(recipientId, id); + return session; + } +} diff --git a/src/main/java/io/icker/factions/core/WorldManager.java b/src/main/java/io/icker/factions/core/WorldManager.java index c6067645..a5f64c45 100644 --- a/src/main/java/io/icker/factions/core/WorldManager.java +++ b/src/main/java/io/icker/factions/core/WorldManager.java @@ -7,35 +7,113 @@ import io.icker.factions.api.persistents.Faction; import io.icker.factions.api.persistents.User; import io.icker.factions.util.Message; +import io.icker.factions.util.WorldUtils; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.ChunkPos; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + public class WorldManager { + private static final int FADE_STEPS = 6; + + private static final HashMap lastSeenFaction = new HashMap<>(); + private static final HashMap announceTicks = new HashMap<>(); + private static final HashMap announceComponent = new HashMap<>(); + private static final HashMap announceFadeTicks = new HashMap<>(); + private static final HashMap announceFaction = new HashMap<>(); + public static void register() { PlayerEvents.ON_MOVE.register(WorldManager::onMove); MiscEvents.ON_MOB_SPAWN_ATTEMPT.register(WorldManager::onMobSpawnAttempt); + ServerTickEvents.END_SERVER_TICK.register( + server -> { + if (announceTicks.isEmpty()) return; + + Iterator> iterator = + announceTicks.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UUID playerId = entry.getKey(); + int ticks = entry.getValue(); + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + Component component = announceComponent.get(playerId); + + if (ticks > 0) { + Integer fade = announceFadeTicks.get(playerId); + if (fade != null) { + if (fade < FADE_STEPS) { + if (player != null) { + Faction fadeFaction = announceFaction.get(playerId); + player.sendOverlayMessage( + AnnouncerManager.buildFadeStep( + fadeFaction, fade, FADE_STEPS)); + } + announceFadeTicks.put(playerId, fade + 1); + } else { + announceFadeTicks.remove(playerId); + if (player != null && component != null) { + player.sendOverlayMessage(component); + } + } + } else if (player != null && component != null && ticks % 20 == 0) { + player.sendOverlayMessage(component); + } + entry.setValue(ticks - 1); + } else { + iterator.remove(); + announceComponent.remove(playerId); + announceFadeTicks.remove(playerId); + announceFaction.remove(playerId); + } + } + }); } private static void onMobSpawnAttempt() { // TODO Implement this } + public static void clearPlayerState(UUID playerId) { + lastSeenFaction.remove(playerId); + announceTicks.remove(playerId); + announceComponent.remove(playerId); + announceFadeTicks.remove(playerId); + announceFaction.remove(playerId); + } + private static void onMove(ServerPlayer player) { User user = User.get(player.getUUID()); + ServerLevel world = (ServerLevel) player.level(); - String dimension = world.dimension().identifier().toString(); + String dimension = WorldUtils.dimensionString(world); - ChunkPos chunkPos = world.getChunk(player.blockPosition()).getPos(); + ChunkPos chunkPos = WorldUtils.getChunkPos(player.blockPosition()); Claim claim = Claim.get(chunkPos.x(), chunkPos.z(), dimension); + Faction claimFaction = claim == null ? null : claim.getFaction(); + + if (claim != null && !claimFaction.isElytraAllowed() && !user.bypass && player.isFallFlying()) { + player.stopFallFlying(); + new Message(Component.translatable("factions.rules.elytra.blocked")) + .format(ChatFormatting.RED) + .send(player, true); + } + + if (!user.autoclaim && !FactionsMod.CONFIG.ANNOUNCER.ENABLED) return; + if (user.autoclaim && claim == null) { Faction faction = user.getFaction(); int requiredPower = - (faction.getClaims().size() + 1) * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; + (faction.getClaimCount() + 1) * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; int maxPower = faction.getUsers().size() * FactionsMod.CONFIG.POWER.MEMBER + FactionsMod.CONFIG.POWER.BASE @@ -58,16 +136,27 @@ private static void onMove(ServerPlayer player) { .send(faction); } } - if (user.radar) { - if (claim != null) { - new Message(claim.getFaction().getName()) - .format(claim.getFaction().getColor()) - .send(player, true); - } else { - new Message(Component.translatable("factions.radar.wilderness")) - .format(ChatFormatting.GREEN) - .send(player, true); - } - } + + claimFaction = claim == null ? null : claim.getFaction(); + + if (!FactionsMod.CONFIG.ANNOUNCER.ENABLED) return; + + UUID playerId = player.getUUID(); + UUID currentFactionId = claim != null ? claimFaction.getID() : null; + if (Objects.equals(currentFactionId, lastSeenFaction.get(playerId))) return; + + lastSeenFaction.put(playerId, currentFactionId); + + announceTicks.remove(playerId); + announceComponent.remove(playerId); + announceFadeTicks.remove(playerId); + announceFaction.remove(playerId); + + Component component = AnnouncerManager.buildAnnouncement(claimFaction); + announceComponent.put(playerId, component); + announceTicks.put(playerId, FactionsMod.CONFIG.ANNOUNCER.DISPLAY_SECONDS * 20); + announceFaction.put(playerId, claimFaction); + announceFadeTicks.put(playerId, 0); + player.sendOverlayMessage(AnnouncerManager.buildFadeStep(claimFaction, 0, FADE_STEPS)); } } diff --git a/src/main/java/io/icker/factions/net/ClaimSyncPayload.java b/src/main/java/io/icker/factions/net/ClaimSyncPayload.java new file mode 100644 index 00000000..326ad72f --- /dev/null +++ b/src/main/java/io/icker/factions/net/ClaimSyncPayload.java @@ -0,0 +1,78 @@ +package io.icker.factions.net; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.List; + +/** + * S2C: server pushes all faction claims to the client on join / claim change. + * Wire format (G2 lightweight): + * int factionCount + * for each faction: + * String factionName + * int argbColor + * int dimCount + * for each dimension: + * String dimensionId (e.g. "minecraft:overworld") + * int chunkCount + * for each chunk: long (ChunkPos.asLong — one long per chunk) + */ +public record ClaimSyncPayload(List factions) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("factions", "claim_sync")); + + public record FactionClaims(String name, int argbColor, List dims) { + public record DimClaims(String dimensionId, List chunkLongs) {} + } + + public static final StreamCodec CODEC = + StreamCodec.of(ClaimSyncPayload::encode, ClaimSyncPayload::decode); + + private static void encode(RegistryFriendlyByteBuf buf, ClaimSyncPayload payload) { + buf.writeVarInt(payload.factions().size()); + for (FactionClaims fc : payload.factions()) { + buf.writeUtf(fc.name()); + buf.writeInt(fc.argbColor()); + buf.writeVarInt(fc.dims().size()); + for (FactionClaims.DimClaims dc : fc.dims()) { + buf.writeUtf(dc.dimensionId()); + buf.writeVarInt(dc.chunkLongs().size()); + for (long l : dc.chunkLongs()) { + buf.writeLong(l); + } + } + } + } + + private static ClaimSyncPayload decode(RegistryFriendlyByteBuf buf) { + int factionCount = buf.readVarInt(); + List factions = new ArrayList<>(factionCount); + for (int i = 0; i < factionCount; i++) { + String name = buf.readUtf(); + int color = buf.readInt(); + int dimCount = buf.readVarInt(); + List dims = new ArrayList<>(dimCount); + for (int d = 0; d < dimCount; d++) { + String dimId = buf.readUtf(); + int chunkCount = buf.readVarInt(); + List chunks = new ArrayList<>(chunkCount); + for (int c = 0; c < chunkCount; c++) { + chunks.add(buf.readLong()); + } + dims.add(new FactionClaims.DimClaims(dimId, chunks)); + } + factions.add(new FactionClaims(name, color, dims)); + } + return new ClaimSyncPayload(factions); + } + + @Override + public CustomPacketPayload.Type type() { + return TYPE; + } +} diff --git a/src/main/java/io/icker/factions/net/PacketRegistry.java b/src/main/java/io/icker/factions/net/PacketRegistry.java new file mode 100644 index 00000000..77c7b754 --- /dev/null +++ b/src/main/java/io/icker/factions/net/PacketRegistry.java @@ -0,0 +1,12 @@ +package io.icker.factions.net; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; + +public final class PacketRegistry { + private PacketRegistry() {} + + /** Call ONCE from the common ModInitializer. PayloadTypeRegistry is shared across sides. */ + public static void register() { + PayloadTypeRegistry.clientboundPlay().register(ClaimSyncPayload.TYPE, ClaimSyncPayload.CODEC); + } +} diff --git a/src/main/java/io/icker/factions/ui/AdminGui.java b/src/main/java/io/icker/factions/ui/AdminGui.java index ec432092..c1065ae5 100644 --- a/src/main/java/io/icker/factions/ui/AdminGui.java +++ b/src/main/java/io/icker/factions/ui/AdminGui.java @@ -1,11 +1,13 @@ package io.icker.factions.ui; +import java.util.List; +import java.util.Optional; + import com.mojang.authlib.GameProfile; import eu.pb4.sgui.api.ClickType; import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; - import io.icker.factions.FactionsMod; import io.icker.factions.api.persistents.Claim; import io.icker.factions.api.persistents.Faction; @@ -13,21 +15,19 @@ import io.icker.factions.util.GuiInteract; import io.icker.factions.util.Icons; import io.icker.factions.util.Message; - import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.item.Items; - import xyz.nucleoid.server.translations.api.Localization; -import java.util.*; - public class AdminGui extends SimpleGui { - private final Runnable defaultReturn = - () -> { + + private final Runnable defaultReturn + = () -> { GuiInteract.playClickSound(player); this.open(); }; @@ -39,11 +39,15 @@ public AdminGui(ServerPlayer player) { // GUI this.setTitle(Component.translatable("factions.gui.admin.title")); - List indexes = - FactionsMod.dynmap == null ? List.of(1, 3, 5, 7) : List.of(0, 2, 4, 6, 8); + List indexes + = FactionsMod.dynmap == null ? List.of(1, 3, 5, 7) : List.of(0, 2, 4, 6, 8); - for (int i = 0; i < 9; i++) - this.setSlot(i, new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE).hideTooltip()); + for (int i = 0; i < 9; i++) { + this.setSlot( + i, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.WHITE)) + .hideTooltip()); + } // Bypass icon this.setSlot(indexes.get(0), buildBypassElement(user)); @@ -57,7 +61,7 @@ public AdminGui(ServerPlayer player) { .setLore( List.of( Component.translatable( - "factions.gui.admin.options.power.lore") + "factions.gui.admin.options.power.lore") .setStyle( Style.EMPTY .withItalic(false) @@ -77,13 +81,13 @@ public AdminGui(ServerPlayer player) { .setLore( List.of( Component.translatable( - "factions.gui.admin.options.spoof.lore1") + "factions.gui.admin.options.spoof.lore1") .setStyle( Style.EMPTY .withItalic(false) .withColor(ChatFormatting.GRAY)), Component.translatable( - "factions.gui.admin.options.spoof.lore2") + "factions.gui.admin.options.spoof.lore2") .setStyle( Style.EMPTY .withItalic(false) @@ -94,8 +98,8 @@ public AdminGui(ServerPlayer player) { if (clickType == ClickType.MOUSE_RIGHT) { user.setSpoof(null); new Message( - Component.translatable( - "factions.gui.admin.options.spoof.clear.success")) + Component.translatable( + "factions.gui.admin.options.spoof.clear.success")) .send(player, false); return; } @@ -111,7 +115,7 @@ public AdminGui(ServerPlayer player) { .setLore( List.of( Component.translatable( - "factions.gui.admin.options.audit.lore") + "factions.gui.admin.options.audit.lore") .setStyle( Style.EMPTY .withItalic(false) @@ -126,8 +130,8 @@ public AdminGui(ServerPlayer player) { } new Message( - Component.translatable( - "factions.gui.admin.options.audit.success")) + Component.translatable( + "factions.gui.admin.options.audit.success")) .send(player, false); })); @@ -143,7 +147,7 @@ public AdminGui(ServerPlayer player) { .setLore( List.of( Component.translatable( - "factions.gui.admin.options.reload_dynmap.lore") + "factions.gui.admin.options.reload_dynmap.lore") .setStyle( Style.EMPTY .withItalic(false) @@ -154,8 +158,8 @@ public AdminGui(ServerPlayer player) { GuiInteract.playClickSound(player); FactionsMod.dynmap.reloadAll(); new Message( - Component.translatable( - "factions.gui.admin.options.reload_dynmap.success")) + Component.translatable( + "factions.gui.admin.options.reload_dynmap.success")) .send(player, false); })); } @@ -178,9 +182,9 @@ private GuiElementBuilder buildBypassElement(User user) { .setLore( List.of( Component.translatable( - user.bypass - ? "factions.gui.admin.options.bypass.lore.disable" - : "factions.gui.admin.options.bypass.lore.enable") + user.bypass + ? "factions.gui.admin.options.bypass.lore.disable" + : "factions.gui.admin.options.bypass.lore.enable") .setStyle( Style.EMPTY .withItalic(false) @@ -193,16 +197,16 @@ private GuiElementBuilder buildBypassElement(User user) { this.setSlot(index, buildBypassElement(user)); new Message( - Component.translatable( - "factions.gui.admin.options.bypass.success")) + Component.translatable( + "factions.gui.admin.options.bypass.success")) .filler("·") .add( new Message( - user.bypass - ? Component.translatable( - "options.on") - : Component.translatable( - "options.off")) + user.bypass + ? Component.translatable( + "options.on") + : Component.translatable( + "options.off")) .format( user.bypass ? ChatFormatting.GREEN @@ -227,18 +231,18 @@ private void execSpoof() { if (!input.matches("^[a-zA-Z0-9_]{2,16}$")) { inputGui.showErrorMessage( Component.translatable( - "factions.gui.spoof.fail.invalid_name", input) + "factions.gui.spoof.fail.invalid_name", input) .withStyle(ChatFormatting.RED), index); return; } Optional profile; - if (!(profile = - player.level() - .getServer() - .services() - .profileResolver() - .fetchByName(input)) + if (!(profile + = player.level() + .getServer() + .services() + .profileResolver() + .fetchByName(input)) .isPresent()) { inputGui.showErrorMessage( Component.translatable("factions.gui.spoof.fail.no_player", input) @@ -275,7 +279,7 @@ private void execPower() { if (selectedFac[0] == null) { inputFacGui.showErrorMessage( Component.translatable( - "factions.gui.power.setfaction.fail.no_faction") + "factions.gui.power.setfaction.fail.no_faction") .withStyle(ChatFormatting.RED), index); return; @@ -305,27 +309,27 @@ private void execPower() { if (selectedPow[0] != 0) { if (selectedPow[0] > 0) { new Message( - Component.translatable( - "factions.gui.power.success.added.faction", - player.getName().getString(), - selectedPow[0])) + Component.translatable( + "factions.gui.power.success.added.faction", + player.getName().getString(), + selectedPow[0])) .send(selectedFac[0]); new Message( - Component.translatable( - "factions.gui.power.success.added.admin", - selectedPow[0])) + Component.translatable( + "factions.gui.power.success.added.admin", + selectedPow[0])) .send(player, false); } else { new Message( - Component.translatable( - "factions.gui.power.success.removed.faction", - player.getName().getString(), - selectedPow[0])) + Component.translatable( + "factions.gui.power.success.removed.faction", + player.getName().getString(), + selectedPow[0])) .send(selectedFac[0]); new Message( - Component.translatable( - "factions.gui.power.success.removed.admin", - selectedPow[0])) + Component.translatable( + "factions.gui.power.success.removed.admin", + selectedPow[0])) .send(player, false); } } else { diff --git a/src/main/java/io/icker/factions/ui/InfoGui.java b/src/main/java/io/icker/factions/ui/InfoGui.java index 3dd4be3b..cd2759c0 100644 --- a/src/main/java/io/icker/factions/ui/InfoGui.java +++ b/src/main/java/io/icker/factions/ui/InfoGui.java @@ -1,17 +1,20 @@ package io.icker.factions.ui; +import java.util.List; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.Nullable; + import com.mojang.authlib.GameProfile; import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; - import io.icker.factions.FactionsMod; import io.icker.factions.api.persistents.Faction; import io.icker.factions.api.persistents.User; import io.icker.factions.util.Command; import io.icker.factions.util.GuiInteract; import io.icker.factions.util.Icons; - import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; @@ -20,16 +23,12 @@ import net.minecraft.server.players.ProfileResolver; import net.minecraft.util.Util; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.item.Items; - -import org.jetbrains.annotations.Nullable; - import xyz.nucleoid.server.translations.api.Localization; -import java.util.List; -import java.util.stream.Collectors; - public class InfoGui extends SimpleGui { + protected final Runnable closeCallback; public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCallback) { @@ -43,25 +42,29 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal boolean isMember = faction.equals(user.getFaction()); List members = faction.getUsers(); - String owner = - members.stream() + String owner + = members.stream() .filter(u -> u.rank == User.Rank.OWNER) .map( - u -> - resolver.fetchById(u.getID()) - .orElse( - new GameProfile( - Util.NIL_UUID, - Localization.raw( - "factions.gui.generic.unknown_player", - player))) - .name()) + u + -> resolver.fetchById(u.getID()) + .orElse( + new GameProfile( + Util.NIL_UUID, + Localization.raw( + "factions.gui.generic.unknown_player", + player))) + .name()) .collect(Collectors.joining(", ")); this.setTitle(Component.translatable("factions.gui.info.title")); - for (int i = 0; i < 9; i++) - this.setSlot(i, new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE).hideTooltip()); + for (int i = 0; i < 9; i++) { + this.setSlot( + i, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.WHITE)) + .hideTooltip()); + } // Faction info this.setSlot( @@ -78,14 +81,13 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal .withItalic(false) .withColor(ChatFormatting.WHITE)), Component.translatable( - "factions.gui.info.owner", - Component.literal(owner) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .YELLOW))) + "factions.gui.info.owner", + Component.literal(owner) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.YELLOW))) .setStyle( Style.EMPTY .withItalic(false) @@ -95,30 +97,30 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal int maxSize = FactionsMod.CONFIG.MAX_FACTION_SIZE; boolean isMaxSize = FactionsMod.CONFIG.MAX_FACTION_SIZE > -1; - List membersLore = - new java.util.ArrayList<>( + List membersLore + = new java.util.ArrayList<>( members.stream() .map( - u -> - Component.literal( - resolver.fetchById(u.getID()) - .orElse( - new GameProfile( - Util - .NIL_UUID, - Localization - .raw( - "factions.gui.generic.unknown_player", - player))) - .name()) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GRAY))) + u + -> Component.literal( + resolver.fetchById(u.getID()) + .orElse( + new GameProfile( + Util.NIL_UUID, + Localization + .raw( + "factions.gui.generic.unknown_player", + player))) + .name()) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GRAY))) .toList()); - if (membersLore.size() > 4) membersLore = membersLore.subList(0, 3); + if (membersLore.size() > 4) { + membersLore = membersLore.subList(0, 3); + } membersLore.add(Component.empty()); membersLore.add( Component.translatable("factions.gui.info.members.viewall") @@ -132,10 +134,10 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal Component.translatable( "factions.gui.info.members", Component.literal( - members.size() - + (isMaxSize - ? ("/" + maxSize) - : "")) + members.size() + + (isMaxSize + ? ("/" + maxSize) + : "")) .withStyle(ChatFormatting.GREEN))) .setLore(membersLore) .setCallback( @@ -145,7 +147,7 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal })); // Power info - int requiredPower = faction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; + int requiredPower = faction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; int maxPower = faction.calculateMaxPower(); this.setSlot( 2, @@ -155,93 +157,90 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal .setLore( List.of( Component.translatable( - "factions.gui.info.power.total", - Component.literal( - String.valueOf( - faction.getPower())) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GREEN))) + "factions.gui.info.power.total", + Component.literal( + String.valueOf( + faction.getPower())) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GREEN))) .setStyle( Style.EMPTY .withItalic(false) .withColor(ChatFormatting.GRAY)), Component.translatable( - "factions.gui.info.power.claims", - Component.literal( - String.valueOf( - requiredPower)) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GREEN))) + "factions.gui.info.power.claims", + Component.literal( + String.valueOf( + requiredPower)) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GREEN))) .setStyle( Style.EMPTY .withItalic(false) .withColor(ChatFormatting.GRAY)), Component.translatable( - "factions.gui.info.power.max", - Component.literal(String.valueOf(maxPower)) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GREEN))) + "factions.gui.info.power.max", + Component.literal(String.valueOf(maxPower)) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GREEN))) .setStyle( Style.EMPTY .withItalic(false) .withColor(ChatFormatting.GRAY))))); // Allies info - List allies = - faction.getMutualAllies().isEmpty() - ? List.of( - Component.translatable("factions.gui.info.allies.none") - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor(ChatFormatting.GRAY))) - : faction.getMutualAllies().stream() - .map(rel -> Faction.get(rel.target)) - .map(fac -> Component.nullToEmpty(fac.getColor() + fac.getName())) - .toList(); + List allies + = faction.getMutualAllies().isEmpty() + ? List.of( + Component.translatable("factions.gui.info.allies.none") + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor(ChatFormatting.GRAY))) + : faction.getMutualAllies().stream() + .map(rel -> Faction.get(rel.target)) + .map(fac -> Component.nullToEmpty(fac.getColor() + fac.getName())) + .toList(); this.setSlot( 3, new GuiElementBuilder(Items.PLAYER_HEAD) .setProfileSkinTexture(Icons.GUI_CASTLE_ALLY) .setName( Component.translatable( - "factions.gui.info.allies.some", - faction.getMutualAllies().size()) + "factions.gui.info.allies.some", + faction.getMutualAllies().size()) .withStyle(ChatFormatting.GREEN)) .setLore(allies)); // Enemies info - List enemies = - faction.getEnemiesWith().isEmpty() - ? List.of( - Component.translatable("factions.gui.info.enemies.none") - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor(ChatFormatting.GRAY))) - : faction.getEnemiesWith().stream() - .map(rel -> Faction.get(rel.target)) - .map(fac -> Component.nullToEmpty(fac.getColor() + fac.getName())) - .toList(); + List enemies + = faction.getEnemiesWith().isEmpty() + ? List.of( + Component.translatable("factions.gui.info.enemies.none") + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor(ChatFormatting.GRAY))) + : faction.getEnemiesWith().stream() + .map(rel -> Faction.get(rel.target)) + .map(fac -> Component.nullToEmpty(fac.getColor() + fac.getName())) + .toList(); this.setSlot( 4, new GuiElementBuilder(Items.PLAYER_HEAD) .setProfileSkinTexture(Icons.GUI_CASTLE_ENEMY) .setName( Component.translatable( - "factions.gui.info.enemies.some", - faction.getEnemiesWith().size()) + "factions.gui.info.enemies.some", + faction.getEnemiesWith().size()) .withStyle(ChatFormatting.RED)) .setLore(enemies)); @@ -254,7 +253,7 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal .setLore( List.of( Component.translatable( - "factions.gui.info.settings.lore") + "factions.gui.info.settings.lore") .withStyle(ChatFormatting.GRAY))) .setCallback( () -> { @@ -268,9 +267,9 @@ public InfoGui(ServerPlayer player, Faction faction, @Nullable Runnable closeCal new GuiElementBuilder(Items.STRUCTURE_VOID) .setName( Component.translatable( - closeCallback == null - ? "factions.gui.generic.close" - : "factions.gui.generic.back") + closeCallback == null + ? "factions.gui.generic.close" + : "factions.gui.generic.back") .withStyle(ChatFormatting.RED)) .setCallback( () -> { diff --git a/src/main/java/io/icker/factions/ui/MemberGui.java b/src/main/java/io/icker/factions/ui/MemberGui.java index 829cce16..bc9ee615 100644 --- a/src/main/java/io/icker/factions/ui/MemberGui.java +++ b/src/main/java/io/icker/factions/ui/MemberGui.java @@ -1,11 +1,16 @@ package io.icker.factions.ui; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; + import com.mojang.authlib.GameProfile; import eu.pb4.sgui.api.ClickType; import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; - import io.icker.factions.api.persistents.Faction; import io.icker.factions.api.persistents.User; import io.icker.factions.command.RankCommand; @@ -13,24 +18,18 @@ import io.icker.factions.util.GuiInteract; import io.icker.factions.util.Icons; import io.icker.factions.util.Message; - import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.players.ProfileResolver; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.item.Items; - -import org.jetbrains.annotations.Nullable; - import xyz.nucleoid.server.translations.api.Localization; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - public class MemberGui extends PagedGui { + Faction faction; int size; ProfileResolver resolver; @@ -69,8 +68,8 @@ protected int getPageAmount() { protected DisplayElement getElement(int id) { if (this.size > id) { var targetUser = this.members.get(id); - GameProfile unknownPlayer = - new GameProfile( + GameProfile unknownPlayer + = new GameProfile( UUID.randomUUID(), Localization.raw("factions.gui.generic.unknown_player", player)); @@ -81,8 +80,8 @@ protected DisplayElement getElement(int id) { icon.setName(Component.literal(profile.name())); if (profile.equals(unknownPlayer)) { - List lore = - List.of( + List lore + = List.of( Component.translatable("factions.gui.members.entry.unknown_player") .setStyle( Style.EMPTY @@ -93,21 +92,20 @@ protected DisplayElement getElement(int id) { return DisplayElement.of(icon); } - List lore = - new ArrayList<>( + List lore + = new ArrayList<>( List.of( Component.translatable( - "factions.gui.members.entry.info.rank", - Component.translatable( - "factions.gui.members.entry.info.rank." - + targetUser - .getRankName()) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GREEN))) + "factions.gui.members.entry.info.rank", + Component.translatable( + "factions.gui.members.entry.info.rank." + + targetUser + .getRankName()) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GREEN))) .setStyle( Style.EMPTY .withItalic(false) @@ -136,8 +134,8 @@ protected DisplayElement getElement(int id) { Style.EMPTY .withItalic(false) .withColor(ChatFormatting.DARK_RED))); - ServerPlayer targetPlayer = - player.level().getServer().getPlayerList().getPlayer(targetUser.getID()); + ServerPlayer targetPlayer + = player.level().getServer().getPlayerList().getPlayer(targetUser.getID()); icon.setCallback( (index, clickType, actionType, _) -> { GuiInteract.playClickSound(player); @@ -145,13 +143,13 @@ protected DisplayElement getElement(int id) { try { RankCommand.execPromote(targetUser, player); new Message( + Component.translatable( + "factions.gui.members.entry.manage.promote.result", + profile.name(), Component.translatable( - "factions.gui.members.entry.manage.promote.result", - profile.name(), - Component.translatable( - "factions.gui.members.entry.info.rank." - + targetUser - .getRankName()))) + "factions.gui.members.entry.info.rank." + + targetUser + .getRankName()))) .prependFaction(faction) .send(player, false); } catch (Exception e) { @@ -166,13 +164,13 @@ protected DisplayElement getElement(int id) { RankCommand.execDemote(targetUser, player); new Message( + Component.translatable( + "factions.gui.members.entry.manage.demote.result", + profile.name(), Component.translatable( - "factions.gui.members.entry.manage.demote.result", - profile.name(), - Component.translatable( - "factions.gui.members.entry.info.rank." - + targetUser - .getRankName()))) + "factions.gui.members.entry.info.rank." + + targetUser + .getRankName()))) .prependFaction(faction) .send(player, false); } catch (Exception e) { @@ -184,11 +182,14 @@ protected DisplayElement getElement(int id) { } if (clickType == ClickType.DROP) { SimpleGui gui = new SimpleGui(MenuType.HOPPER, player, false); - for (int i = 0; i < 5; i++) + for (int i = 0; i < 5; i++) { gui.setSlot( i, - new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE) + new GuiElementBuilder( + Items.STAINED_GLASS_PANE.pick( + DyeColor.WHITE)) .hideTooltip()); + } gui.setTitle( Component.translatable( "factions.gui.members.entry.manage.kick.confirm.title")); @@ -197,8 +198,8 @@ protected DisplayElement getElement(int id) { new GuiElementBuilder(Items.SLIME_BALL) .setName( Component.translatable( - "factions.gui.members.entry.manage.kick.confirm.yes", - profile.name()) + "factions.gui.members.entry.manage.kick.confirm.yes", + profile.name()) .withStyle(ChatFormatting.GREEN)) .setCallback( ((index2, @@ -206,16 +207,14 @@ protected DisplayElement getElement(int id) { actionType2, gui2) -> { if (user.rank == User.Rank.LEADER - && (targetUser.rank - == User.Rank - .LEADER - || targetUser.rank - == User.Rank - .OWNER)) { + && (targetUser.rank + == User.Rank.LEADER + || targetUser.rank + == User.Rank.OWNER)) { new Message( - Component - .translatable( - "factions.command.kick.fail.high_rank")) + Component + .translatable( + "factions.command.kick.fail.high_rank")) .format(ChatFormatting.RED) .send(player, false); return; @@ -224,18 +223,18 @@ protected DisplayElement getElement(int id) { GuiInteract.playClickSound(player); targetUser.leaveFaction(); new Message( - Component.translatable( - "factions.gui.members.entry.manage.kick.result.actor", - profile.name())) + Component.translatable( + "factions.gui.members.entry.manage.kick.result.actor", + profile.name())) .send(player, false); if (targetPlayer != null) { new Message( - Component - .translatable( - "factions.gui.members.entry.manage.kick.result.subject", - player.getName() - .getString())) + Component + .translatable( + "factions.gui.members.entry.manage.kick.result.subject", + player.getName() + .getString())) .send(targetPlayer, false); } this.open(); @@ -245,7 +244,7 @@ protected DisplayElement getElement(int id) { new GuiElementBuilder(Items.STRUCTURE_VOID) .setName( Component.translatable( - "factions.gui.members.entry.manage.kick.confirm.no") + "factions.gui.members.entry.manage.kick.confirm.no") .withStyle(ChatFormatting.RED)) .setCallback( () -> { @@ -257,17 +256,16 @@ protected DisplayElement getElement(int id) { lore.removeFirst(); lore.addFirst( Component.translatable( - "factions.gui.members.entry.info.rank", - Component.translatable( - "factions.gui.members.entry.info.rank." - + targetUser - .getRankName()) - .setStyle( - Style.EMPTY - .withItalic(false) - .withColor( - ChatFormatting - .GREEN))) + "factions.gui.members.entry.info.rank", + Component.translatable( + "factions.gui.members.entry.info.rank." + + targetUser + .getRankName()) + .setStyle( + Style.EMPTY + .withItalic(false) + .withColor( + ChatFormatting.GREEN))) .setStyle( Style.EMPTY .withItalic(false) diff --git a/src/main/java/io/icker/factions/ui/ModifyGui.java b/src/main/java/io/icker/factions/ui/ModifyGui.java index 5027c1ab..dc82160e 100644 --- a/src/main/java/io/icker/factions/ui/ModifyGui.java +++ b/src/main/java/io/icker/factions/ui/ModifyGui.java @@ -1,29 +1,28 @@ package io.icker.factions.ui; +import org.jetbrains.annotations.Nullable; + import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; - import io.icker.factions.api.persistents.Faction; import io.icker.factions.command.ModifyCommand; import io.icker.factions.util.GuiInteract; import io.icker.factions.util.Icons; import io.icker.factions.util.Message; - import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.item.Items; - -import org.jetbrains.annotations.Nullable; - import xyz.nucleoid.server.translations.api.Localization; public class ModifyGui extends SimpleGui { + Runnable closeCallback; - private final Runnable defaultReturn = - () -> { + private final Runnable defaultReturn + = () -> { GuiInteract.playClickSound(player); this.open(); }; @@ -33,8 +32,12 @@ public ModifyGui(ServerPlayer player, Faction faction, @Nullable Runnable closeC this.closeCallback = closeCallback; this.setTitle(Component.translatable("factions.gui.modify.title")); - for (int i = 0; i < 9; i++) - this.setSlot(i, new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE).hideTooltip()); + for (int i = 0; i < 9; i++) { + this.setSlot( + i, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.WHITE)) + .hideTooltip()); + } this.setSlot( 0, @@ -99,12 +102,12 @@ private GuiElementBuilder buildOpenFactionButton(Faction faction) { Component.translatable( "factions.gui.modify.faction_type", faction.isOpen() - ? Component.translatable( - "factions.gui.modify.faction_type.public") - .withStyle(ChatFormatting.AQUA) - : Component.translatable( - "factions.gui.modify.faction_type.invite") - .withStyle(ChatFormatting.RED))) + ? Component.translatable( + "factions.gui.modify.faction_type.public") + .withStyle(ChatFormatting.AQUA) + : Component.translatable( + "factions.gui.modify.faction_type.invite") + .withStyle(ChatFormatting.RED))) .setCallback( (index, clickType, actionType, _) -> { faction.setOpen(!faction.isOpen()); @@ -127,8 +130,8 @@ private void execName(Faction faction) { try { ModifyCommand.execName(player, faction, name); new Message( - Component.translatable( - "factions.gui.modify.change_name.result", name)) + Component.translatable( + "factions.gui.modify.change_name.result", name)) .prependFaction(faction) .send(player, false); } catch (Exception e) { @@ -156,8 +159,8 @@ private void execDesc(Faction faction) { String desc = inputGui.getInput(); faction.setDescription(desc); new Message( - Component.translatable( - "factions.gui.modify.change_description.result", desc)) + Component.translatable( + "factions.gui.modify.change_description.result", desc)) .prependFaction(faction) .send(player, false); this.open(); @@ -179,8 +182,8 @@ private void execMOTD(Faction faction) { String motd = inputGui.getInput(); faction.setMOTD(motd); new Message( - Component.translatable( - "factions.gui.modify.change_motd.result", motd)) + Component.translatable( + "factions.gui.modify.change_motd.result", motd)) .prependFaction(faction) .send(player, false); this.open(); @@ -191,6 +194,7 @@ private void execMOTD(Faction faction) { } class ColorGui extends SimpleGui { + ServerPlayer player; Faction faction; Runnable returnCallback; @@ -215,110 +219,111 @@ public ColorGui(ServerPlayer player, Faction faction, @Nullable Runnable returnC Component.translatable( "factions.gui.modify.change_color.select.option.reset")) .setCallback(() -> colorCallback(ChatFormatting.RESET))); - this.addSlot(new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE).hideTooltip()); this.addSlot( - new GuiElementBuilder(Items.RED_CONCRETE_POWDER) + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.WHITE)).hideTooltip()); + this.addSlot( + new GuiElementBuilder(Items.CONCRETE_POWDER.pick(DyeColor.RED)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.red") + "factions.gui.modify.change_color.select.option.red") .withStyle(ChatFormatting.RED)) .setCallback(() -> colorCallback(ChatFormatting.RED))); this.addSlot( - new GuiElementBuilder(Items.LIGHT_BLUE_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.LIGHT_BLUE)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.aqua") + "factions.gui.modify.change_color.select.option.aqua") .withStyle(ChatFormatting.AQUA)) .setCallback(() -> colorCallback(ChatFormatting.AQUA))); this.addSlot( - new GuiElementBuilder(Items.BLACK_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.BLACK)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.black") + "factions.gui.modify.change_color.select.option.black") .withStyle(ChatFormatting.BLACK)) .setCallback(() -> colorCallback(ChatFormatting.BLACK))); this.addSlot( - new GuiElementBuilder(Items.BLUE_CONCRETE_POWDER) + new GuiElementBuilder(Items.CONCRETE_POWDER.pick(DyeColor.BLUE)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.blue") + "factions.gui.modify.change_color.select.option.blue") .withStyle(ChatFormatting.BLUE)) .setCallback(() -> colorCallback(ChatFormatting.BLUE))); this.addSlot( - new GuiElementBuilder(Items.CYAN_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.CYAN)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_aqua") + "factions.gui.modify.change_color.select.option.dark_aqua") .withStyle(ChatFormatting.DARK_AQUA)) .setCallback(() -> colorCallback(ChatFormatting.DARK_AQUA))); this.addSlot( - new GuiElementBuilder(Items.BLUE_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.BLUE)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_blue") + "factions.gui.modify.change_color.select.option.dark_blue") .withStyle(ChatFormatting.DARK_BLUE)) .setCallback(() -> colorCallback(ChatFormatting.DARK_BLUE))); this.addSlot( - new GuiElementBuilder(Items.GRAY_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.GRAY)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_gray") + "factions.gui.modify.change_color.select.option.dark_gray") .withStyle(ChatFormatting.DARK_GRAY)) .setCallback(() -> colorCallback(ChatFormatting.DARK_GRAY))); this.addSlot( - new GuiElementBuilder(Items.LIGHT_GRAY_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.LIGHT_GRAY)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.gray") + "factions.gui.modify.change_color.select.option.gray") .withStyle(ChatFormatting.GRAY)) .setCallback(() -> colorCallback(ChatFormatting.GRAY))); this.addSlot( - new GuiElementBuilder(Items.GREEN_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.GREEN)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_green") + "factions.gui.modify.change_color.select.option.dark_green") .withStyle(ChatFormatting.DARK_GREEN)) .setCallback(() -> colorCallback(ChatFormatting.DARK_GREEN))); this.addSlot( - new GuiElementBuilder(Items.LIME_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.LIME)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.green") + "factions.gui.modify.change_color.select.option.green") .withStyle(ChatFormatting.GREEN)) .setCallback(() -> colorCallback(ChatFormatting.GREEN))); this.addSlot( - new GuiElementBuilder(Items.MAGENTA_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.MAGENTA)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_purple") + "factions.gui.modify.change_color.select.option.dark_purple") .withStyle(ChatFormatting.DARK_PURPLE)) .setCallback(() -> colorCallback(ChatFormatting.DARK_PURPLE))); this.addSlot( - new GuiElementBuilder(Items.PINK_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.PINK)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.light_purple") + "factions.gui.modify.change_color.select.option.light_purple") .withStyle(ChatFormatting.LIGHT_PURPLE)) .setCallback(() -> colorCallback(ChatFormatting.LIGHT_PURPLE))); this.addSlot( - new GuiElementBuilder(Items.RED_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.RED)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.dark_red") + "factions.gui.modify.change_color.select.option.dark_red") .withStyle(ChatFormatting.DARK_RED)) .setCallback(() -> colorCallback(ChatFormatting.DARK_RED))); this.addSlot( - new GuiElementBuilder(Items.YELLOW_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.YELLOW)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.gold") + "factions.gui.modify.change_color.select.option.gold") .withStyle(ChatFormatting.GOLD)) .setCallback(() -> colorCallback(ChatFormatting.GOLD))); this.addSlot( - new GuiElementBuilder(Items.WHITE_CONCRETE) + new GuiElementBuilder(Items.CONCRETE.pick(DyeColor.WHITE)) .setName( Component.translatable( - "factions.gui.modify.change_color.select.option.white") + "factions.gui.modify.change_color.select.option.white") .withStyle(ChatFormatting.WHITE)) .setCallback(() -> colorCallback(ChatFormatting.WHITE))); this.open(); @@ -333,12 +338,12 @@ private void colorCallback(ChatFormatting color) { .send(player, false); } else { new Message( + Component.translatable( + "factions.gui.modify.change_color.result.color", Component.translatable( - "factions.gui.modify.change_color.result.color", - Component.translatable( - "factions.gui.modify.change_color.color." - + color.name().toLowerCase()) - .setStyle(Style.EMPTY.withColor(color).withBold(true)))) + "factions.gui.modify.change_color.color." + + color.name().toLowerCase()) + .setStyle(Style.EMPTY.withColor(color).withBold(true)))) .prependFaction(faction) .send(player, false); } diff --git a/src/main/java/io/icker/factions/ui/PagedGui.java b/src/main/java/io/icker/factions/ui/PagedGui.java index 0698ba29..01a7cf4d 100644 --- a/src/main/java/io/icker/factions/ui/PagedGui.java +++ b/src/main/java/io/icker/factions/ui/PagedGui.java @@ -1,27 +1,27 @@ package io.icker.factions.ui; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + import eu.pb4.sgui.api.elements.GuiElement; import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; - import io.icker.factions.util.GuiInteract; import io.icker.factions.util.Icons; - import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvents; import net.minecraft.world.inventory.MenuType; import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; - // Shamelessly stolen from Get off My Lawn https://github.com/Patbox/get-off-my-lawn-reserved @ApiStatus.Internal public abstract class PagedGui extends SimpleGui { + public static final int PAGE_SIZE = 9 * 4; protected final Runnable closeCallback; protected int page = 0; @@ -104,33 +104,37 @@ protected int getPage() { protected DisplayElement getNavElement(int id) { return switch (id) { - case 1 -> DisplayElement.previousPage(this); - case 3 -> DisplayElement.nextPage(this); + case 1 -> + DisplayElement.previousPage(this); + case 3 -> + DisplayElement.nextPage(this); case 7 -> - DisplayElement.of( - new GuiElementBuilder(Items.STRUCTURE_VOID) - .setName( - Component.translatable( - this.closeCallback != null - ? "factions.gui.generic.back" - : "factions.gui.generic.close") - .withStyle(ChatFormatting.RED)) - .hideDefaultTooltip() - .setCallback( - (x, y, z, _) -> { - playClickSound(this.player); - this.close(this.closeCallback != null); - })); - default -> DisplayElement.filler(); + DisplayElement.of( + new GuiElementBuilder(Items.STRUCTURE_VOID) + .setName( + Component.translatable( + this.closeCallback != null + ? "factions.gui.generic.back" + : "factions.gui.generic.close") + .withStyle(ChatFormatting.RED)) + .hideDefaultTooltip() + .setCallback( + (x, y, z, _) -> { + playClickSound(this.player); + this.close(this.closeCallback != null); + })); + default -> + DisplayElement.filler(); }; } public record DisplayElement(@Nullable GuiElement element, @Nullable Slot slot) { - private static final DisplayElement EMPTY = - DisplayElement.of(GuiElementBuilder.from(ItemStack.EMPTY).build()); - private static final DisplayElement FILLER = - DisplayElement.of( - new GuiElementBuilder(Items.WHITE_STAINED_GLASS_PANE) + + private static final DisplayElement EMPTY + = DisplayElement.of(GuiElementBuilder.from(ItemStack.EMPTY).build()); + private static final DisplayElement FILLER + = DisplayElement.of( + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.WHITE)) .setName(Component.empty()) .hideTooltip()); diff --git a/src/main/java/io/icker/factions/ui/TradePlacementGui.java b/src/main/java/io/icker/factions/ui/TradePlacementGui.java new file mode 100644 index 00000000..7c13597a --- /dev/null +++ b/src/main/java/io/icker/factions/ui/TradePlacementGui.java @@ -0,0 +1,139 @@ +package io.icker.factions.ui; + +import eu.pb4.sgui.api.elements.GuiElementBuilder; +import eu.pb4.sgui.api.gui.SimpleGui; + +import io.icker.factions.core.TradeSession; +import io.icker.factions.core.TradeSession.Cause; +import io.icker.factions.core.TradeSession.Phase; +import io.icker.factions.util.WorldUtils; + +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.Items; + +import java.util.UUID; + +/** + * Phase 1 of the /f trade flow: each player privately places items in their own 27-slot chest. + * + *

Slots 0-17 (rows 1-2) are redirected via {@link Slot} onto the session's per-player {@link + * SimpleContainer}, so vanilla handles all click/drag/quick-move interactions natively and mutates + * the container directly. Row 3 (slots 18-26) is the control row: slot 22 is the slimeball CONFIRM, + * slot 24 is the magma-cream CANCEL, and the remaining row-3 slots hold gray glass-pane filler with + * no callback. Because filler slots are set via {@link GuiElementBuilder} (no backing container), + * sgui blocks item movement into them by default — no {@code onAnyClick} override needed. + */ +public class TradePlacementGui extends SimpleGui { + + public static final int SLOT_CONFIRM = 22; + public static final int SLOT_CANCEL = 24; + public static final int PLACEMENT_SLOT_COUNT = 18; + + private static final int[] FILLER_SLOTS = {18, 19, 20, 21, 23, 25, 26}; + + private final UUID sessionId; + private boolean confirmed = false; + + public TradePlacementGui(ServerPlayer player, UUID sessionId) { + super(MenuType.GENERIC_9x3, player, false); + this.sessionId = sessionId; + + TradeSession session = + TradeSession.getFor(player.getUUID()) + .orElseThrow( + () -> + new IllegalStateException( + "No active trade session for player " + + player.getUUID())); + + if (session.getPhase() != Phase.PLACEMENT) { + throw new IllegalStateException( + "Trade session " + sessionId + " is not in PLACEMENT phase: " + + session.getPhase()); + } + + SimpleContainer placement = session.ownPlacementFor(player); + + this.setTitle(Component.translatable("factions.gui.trade.placement.title")); + + for (int i = 0; i < PLACEMENT_SLOT_COUNT; i++) { + this.setSlot(i, new Slot(placement, i, 0, 0)); + } + + for (int slot : FILLER_SLOTS) { + this.setSlot( + slot, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.GRAY)) + .hideTooltip()); + } + + this.setSlot( + SLOT_CONFIRM, + new GuiElementBuilder(Items.SLIME_BALL) + .setName( + Component.translatable("factions.gui.trade.placement.confirm")) + .setCallback( + (index, clickType, actionType, gui) -> { + if (confirmed) return; + confirmed = true; + TradeSession.confirmPlacement(sessionId, player.getUUID()); + TradeSession.getFor(player.getUUID()) + .ifPresent( + s -> { + if (s.getPhase() != Phase.REVEAL) return; + MinecraftServer srv = WorldUtils.server; + if (srv == null) return; + ServerPlayer req = + srv.getPlayerList() + .getPlayer( + s.getRequesterId()); + ServerPlayer rec = + srv.getPlayerList() + .getPlayer( + s.getRecipientId()); + // TradeRevealGui does NOT auto-open — + // caller must invoke .open(). + if (req != null) { + new TradeRevealGui(req, s.getId()) + .open(); + } + if (rec != null) { + new TradeRevealGui(rec, s.getId()) + .open(); + } + }); + })); + + this.setSlot( + SLOT_CANCEL, + new GuiElementBuilder(Items.MAGMA_CREAM) + .setName( + Component.translatable("factions.gui.trade.placement.cancel")) + .setCallback( + (index, clickType, actionType, gui) -> { + TradeSession.cancel(sessionId, Cause.PLAYER_CANCELLED); + })); + + session.setGuiFor(player.getUUID(), this); + + this.open(); + } + + @Override + public void onPlayerClose(boolean immediate) { + TradeSession.getFor(player.getUUID()) + .ifPresent( + session -> { + if (session.getPhase() == Phase.PLACEMENT && !confirmed) { + TradeSession.cancel(sessionId, Cause.GUI_CLOSED); + } + }); + super.onPlayerClose(immediate); + } +} diff --git a/src/main/java/io/icker/factions/ui/TradeRevealGui.java b/src/main/java/io/icker/factions/ui/TradeRevealGui.java new file mode 100644 index 00000000..8c0d0e1c --- /dev/null +++ b/src/main/java/io/icker/factions/ui/TradeRevealGui.java @@ -0,0 +1,184 @@ +package io.icker.factions.ui; + +import eu.pb4.sgui.api.ClickType; +import eu.pb4.sgui.api.elements.GuiElementBuilder; +import eu.pb4.sgui.api.gui.SimpleGui; + +import io.icker.factions.core.TradeSession; +import io.icker.factions.core.TradeSession.Cause; +import io.icker.factions.core.TradeSession.Phase; +import io.icker.factions.util.WorldUtils; + +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import java.util.UUID; + +/** + * Phase 2 of the trade flow: both players see each other's placed items (read-only) and choose + * ACCEPT (slimeball), NEGOTIATE (ender pearl = back to Phase 1), or DECLINE (magma cream). + * + *

Anti-dupe invariant: NO slot in this GUI is linked to a live container. Every slot is a + * static {@link GuiElementBuilder} copy of an {@link ItemStack}. All clicks except the three + * action buttons are blocked at the outer {@link #onAnyClick} gate, and every per-slot callback + * is an empty lambda — a click can neither reach vanilla item movement nor a live redirect. + */ +public class TradeRevealGui extends SimpleGui { + + public static final int SLOT_ACCEPT = 20; + public static final int SLOT_NEGOTIATE = 22; + public static final int SLOT_DECLINE = 24; + + private final UUID sessionId; + private boolean accepted = false; + + public TradeRevealGui(ServerPlayer player, UUID sessionId) { + super(MenuType.GENERIC_9x3, player, false); + this.sessionId = sessionId; + + this.setTitle(Component.translatable("factions.gui.trade.reveal.title")); + + TradeSession session = + TradeSession.getFor(player.getUUID()) + .orElseThrow( + () -> new IllegalStateException( + "No active trade session for player " + + player.getUUID())); + + // Slots 0-17: static copies of the OTHER player's placement items (read-only). + // .copy() ensures the GUI element cannot alias the live SimpleContainer's stacks. + SimpleContainer other = session.otherPlacementFor(player); + for (int i = 0; i < 18; i++) { + ItemStack stack = other.getItem(i).copy(); + if (stack.isEmpty()) { + this.setSlot( + i, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.GRAY)) + .hideTooltip() + .setCallback((index, clickType, input, gui) -> {})); + } else { + this.setSlot( + i, + GuiElementBuilder.from(stack) + .setCallback((index, clickType, input, gui) -> {})); + } + } + + // Filler slots in the button row (slots 18, 19, 21, 23, 25, 26). + int[] fillerSlots = {18, 19, 21, 23, 25, 26}; + for (int slot : fillerSlots) { + this.setSlot( + slot, + new GuiElementBuilder(Items.STAINED_GLASS_PANE.pick(DyeColor.GRAY)) + .setName(Component.translatable("factions.trade.filler")) + .hideTooltip() + .setCallback((index, clickType, input, gui) -> {})); + } + + // ACCEPT (slot 20 = slimeball). If both players accept, TradeSession transitions to + // SWAPPING and invokes commit() (Task 10 fills the atomic-swap body). + this.setSlot( + SLOT_ACCEPT, + new GuiElementBuilder(Items.SLIME_BALL) + .setName(Component.translatable("factions.trade.accept")) + .setCallback( + (index, clickType, input, gui) -> { + if (accepted) return; + accepted = true; + TradeSession.acceptReveal(sessionId, player.getUUID()); + })); + + // NEGOTIATE (slot 22 = ender pearl). Resets both confirm/accept flags and moves phase + // back to PLACEMENT — items in the placement containers are untouched. Task 9 wires the + // "after-renegotiate open-TradePlacementGui" flow for both players. + this.setSlot( + SLOT_NEGOTIATE, + new GuiElementBuilder(Items.ENDER_PEARL) + .setName(Component.translatable("factions.trade.negotiate")) + .setCallback( + (index, clickType, input, gui) -> { + TradeSession.renegotiate(sessionId); + TradeSession.getFor(player.getUUID()) + .ifPresent( + s -> { + if (s.getPhase() != Phase.PLACEMENT) return; + MinecraftServer srv = WorldUtils.server; + if (srv == null) return; + ServerPlayer req = + srv.getPlayerList() + .getPlayer( + s.getRequesterId()); + ServerPlayer rec = + srv.getPlayerList() + .getPlayer( + s.getRecipientId()); + // TradePlacementGui auto-opens in its + // constructor — do NOT call .open(). + if (req != null) { + new TradePlacementGui(req, s.getId()); + } + if (rec != null) { + new TradePlacementGui(rec, s.getId()); + } + }); + })); + + // DECLINE (slot 24 = magma cream). Cancels with Cause.DECLINED — items are returned to + // both players' inventories via TradeSession.cancel's inventoryImpl seam. + this.setSlot( + SLOT_DECLINE, + new GuiElementBuilder(Items.MAGMA_CREAM) + .setName(Component.translatable("factions.trade.decline")) + .setCallback( + (index, clickType, input, gui) -> { + TradeSession.cancel(sessionId, Cause.DECLINED); + })); + + // Register this GUI with the session so TradeSession.cancel() / commit() can close it. + session.setGuiFor(player.getUUID(), this); + } + + /** + * Belt-and-suspenders: block ALL non-button clicks BEFORE they reach per-slot callbacks or + * vanilla item-movement handlers. The per-slot empty callbacks are the primary layer; this + * outer gate is the backup. + * + *

Returning {@code true} for the three action buttons lets the click flow through to the + * per-slot {@link GuiElementBuilder} callback registered above. Returning {@code false} for + * every other slot cancels the click entirely — no drag, shift-click, double-click, hotbar + * swap, or drop-key can move an item. + */ + @Override + public boolean onAnyClick(int index, ClickType type, ContainerInput input) { + if (index == SLOT_ACCEPT || index == SLOT_NEGOTIATE || index == SLOT_DECLINE) { + return super.onAnyClick(index, type, input); + } + return false; + } + + /** + * If the player closes the GUI (ESC / walk-away / disconnect) without accepting, cancel the + * session. The phase guard prevents double-cancel when a programmatic close (from + * {@link TradeSession#cancel} after DECLINE, or a phase transition to PLACEMENT via + * {@link TradeSession#renegotiate}, or to SWAPPING via {@link TradeSession#acceptReveal}) + * triggered the close. Mirrors {@code TradePlacementGui#onPlayerClose} for consistency. + */ + @Override + public void onPlayerClose(boolean immediate) { + TradeSession.getFor(player.getUUID()) + .ifPresent( + session -> { + if (session.getPhase() == Phase.REVEAL && !accepted) { + TradeSession.cancel(sessionId, Cause.GUI_CLOSED); + } + }); + super.onPlayerClose(immediate); + } +} diff --git a/src/main/java/io/icker/factions/util/BlueMapWrapper.java b/src/main/java/io/icker/factions/util/BlueMapWrapper.java index fd7ec69e..337faa53 100644 --- a/src/main/java/io/icker/factions/util/BlueMapWrapper.java +++ b/src/main/java/io/icker/factions/util/BlueMapWrapper.java @@ -1,5 +1,12 @@ package io.icker.factions.util; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + import com.flowpowered.math.vector.Vector2d; import com.flowpowered.math.vector.Vector2i; @@ -11,24 +18,16 @@ import de.bluecolored.bluemap.api.markers.POIMarker; import de.bluecolored.bluemap.api.math.Color; import de.bluecolored.bluemap.api.math.Shape; - import io.icker.factions.FactionsMod; import io.icker.factions.api.events.ClaimEvents; import io.icker.factions.api.events.FactionEvents; import io.icker.factions.api.persistents.Claim; import io.icker.factions.api.persistents.Faction; import io.icker.factions.api.persistents.Home; - import net.minecraft.server.level.ServerLevel; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; - public class BlueMapWrapper { + private HashMap markerSets = new HashMap<>(); private BlueMapAPI api; private boolean loadWhenReady = false; @@ -88,27 +87,27 @@ private void generateMarkers() { String info = getInfo(faction); - for (Map.Entry> entry : - ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { + for (Map.Entry> entry + : ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { String level = entry.getKey(); - for (Map group : - ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { - List> outlines = - ClaimGrouper.convertLineSegmentsToOutlines(group); - List shapes = - outlines.stream() + for (Map group + : ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { + List> outlines + = ClaimGrouper.convertLineSegmentsToOutlines(group); + List shapes + = outlines.stream() .map( - (hole) -> - new Shape( - hole.stream() - .map( - (point) -> - new Vector2d( - point - .getX(), - point - .getY())) - .collect(Collectors.toList()))) + (hole) + -> new Shape( + hole.stream() + .map( + (point) + -> new Vector2d( + point + .getX(), + point + .getY())) + .collect(Collectors.toList()))) .collect(Collectors.toList()); MarkerSet markerSet = markerSets.get(level); @@ -124,8 +123,8 @@ private void generateMarkers() { markerSets.put(level, markerSet); } - ExtrudeMarker marker = - ExtrudeMarker.builder() + ExtrudeMarker marker + = ExtrudeMarker.builder() .position( (double) outlines.get(0).get(0).getX(), 320, @@ -133,9 +132,9 @@ private void generateMarkers() { .shape(shapes.removeFirst(), -64, 320) .holes(shapes.toArray(new Shape[0])) .fillColor( - new Color(faction.getColor().getColor() | 0x40000000)) + new Color(faction.getColorValue() | 0x40000000)) .lineColor( - new Color(faction.getColor().getColor() | 0xFF000000)) + new Color(faction.getColorValue() | 0xFF000000)) .label(faction.getName()) .detail(info) .build(); @@ -178,8 +177,8 @@ private void setHome(Faction faction, Home home) { Marker marker = markerSet.get(faction.getID().toString() + "-home"); if (marker == null) { - POIMarker homeMarker = - POIMarker.builder() + POIMarker homeMarker + = POIMarker.builder() .position(home.x, home.y, home.z) .detail(getInfo(faction)) .label(faction.getName() + "'s Home") diff --git a/src/main/java/io/icker/factions/util/Command.java b/src/main/java/io/icker/factions/util/Command.java index 5cd92bae..b4f08412 100644 --- a/src/main/java/io/icker/factions/util/Command.java +++ b/src/main/java/io/icker/factions/util/Command.java @@ -156,9 +156,15 @@ static SuggestionProvider allPlayersInYourFactionButYou() { || !user.getFaction().equals(currentUser.getFaction())) { continue; } - Optional player; - if ((player = resolver.fetchById(user.getID())).isPresent()) { - builder.suggest(player.get().name()); + ServerPlayer online = + context.getSource().getServer().getPlayerList().getPlayer(user.getID()); + if (online != null) { + builder.suggest(online.getName().getString()); + continue; + } + Optional profile = resolver.fetchById(user.getID()); + if (profile.isPresent()) { + builder.suggest(profile.get().name()); } else { builder.suggest(user.getID().toString()); } @@ -167,6 +173,31 @@ static SuggestionProvider allPlayersInYourFactionButYou() { }; } + static SuggestionProvider onlineFactionMembersButYou() { + return (context, builder) -> { + ServerPlayer self = context.getSource().getPlayerOrException(); + User currentUser = Command.getUser(self); + + if (!currentUser.isInFaction()) { + return builder.buildFuture(); + } + + Faction faction = currentUser.getFaction(); + for (ServerPlayer online : + context.getSource().getServer().getPlayerList().getPlayers()) { + if (online.getUUID().equals(self.getUUID())) continue; + + User onlineUser = User.get(online.getUUID()); + if (!onlineUser.isInFaction() + || !onlineUser.getFaction().getID().equals(faction.getID())) { + continue; + } + builder.suggest(online.getName().getString()); + } + return builder.buildFuture(); + }; + } + public static SuggestionProvider openFactions() { return suggest( user -> diff --git a/src/main/java/io/icker/factions/util/DynmapWrapper.java b/src/main/java/io/icker/factions/util/DynmapWrapper.java index 7f021ef2..ae80d94a 100644 --- a/src/main/java/io/icker/factions/util/DynmapWrapper.java +++ b/src/main/java/io/icker/factions/util/DynmapWrapper.java @@ -1,5 +1,19 @@ package io.icker.factions.util; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.dynmap.DynmapCommonAPI; +import org.dynmap.DynmapCommonAPIListener; +import org.dynmap.markers.AreaMarker; +import org.dynmap.markers.GenericMarker; +import org.dynmap.markers.Marker; +import org.dynmap.markers.MarkerAPI; +import org.dynmap.markers.MarkerSet; +import org.dynmap.markers.PolyLineMarker; + import com.flowpowered.math.vector.Vector2i; import io.icker.factions.FactionsMod; @@ -8,27 +22,13 @@ import io.icker.factions.api.persistents.Claim; import io.icker.factions.api.persistents.Faction; import io.icker.factions.api.persistents.Home; - import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; -import org.dynmap.DynmapCommonAPI; -import org.dynmap.DynmapCommonAPIListener; -import org.dynmap.markers.AreaMarker; -import org.dynmap.markers.GenericMarker; -import org.dynmap.markers.Marker; -import org.dynmap.markers.MarkerAPI; -import org.dynmap.markers.MarkerSet; -import org.dynmap.markers.PolyLineMarker; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - public class DynmapWrapper { + private DynmapCommonAPI api; private MarkerAPI markerApi; private MarkerSet markerSet; @@ -37,23 +37,23 @@ public class DynmapWrapper { public DynmapWrapper() { DynmapCommonAPIListener.register( new DynmapCommonAPIListener() { - @Override - public void apiEnabled(DynmapCommonAPI dCAPI) { - api = dCAPI; - markerApi = api.getMarkerAPI(); - markerSet = markerApi.getMarkerSet("dynmap-factions"); - if (markerSet == null) { - markerSet = - markerApi.createMarkerSet( - "dynmap-factions", - "The Dynmap Factions integration", - null, - true); - } - markerSet.getMarkers().forEach(GenericMarker::deleteMarker); - generateMarkers(); - } - }); + @Override + public void apiEnabled(DynmapCommonAPI dCAPI) { + api = dCAPI; + markerApi = api.getMarkerAPI(); + markerSet = markerApi.getMarkerSet("dynmap-factions"); + if (markerSet == null) { + markerSet + = markerApi.createMarkerSet( + "dynmap-factions", + "The Dynmap Factions integration", + null, + true); + } + markerSet.getMarkers().forEach(GenericMarker::deleteMarker); + generateMarkers(); + } + }); ClaimEvents.ADD.register( (claim) -> { @@ -106,41 +106,41 @@ private void generateMarkers() { for (Claim claim : faction.getClaims()) { ChunkPos pos = new ChunkPos(claim.x, claim.z); - AreaMarker marker = - markerSet.createAreaMarker( - claim.getKey(), + AreaMarker marker + = markerSet.createAreaMarker( + claim.level + '-' + claim.x + '-' + claim.z, info, true, dimensionTagToID(claim.level), - new double[] {pos.getMinBlockX(), pos.getMaxBlockX() + 1}, - new double[] {pos.getMinBlockZ(), pos.getMaxBlockZ() + 1}, + new double[]{pos.getMinBlockX(), pos.getMaxBlockX() + 1}, + new double[]{pos.getMinBlockZ(), pos.getMaxBlockZ() + 1}, true); if (marker != null) { - marker.setFillStyle(marker.getFillOpacity(), faction.getColor().getColor()); + marker.setFillStyle(marker.getFillOpacity(), faction.getColorValue()); marker.setLineStyle(0, 0, 0); } } - for (Map.Entry> entry : - ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { + for (Map.Entry> entry + : ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { String level = entry.getKey(); - for (Map group : - ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { - for (List outline : - ClaimGrouper.convertLineSegmentsToOutlines(group)) { + for (Map group + : ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { + for (List outline + : ClaimGrouper.convertLineSegmentsToOutlines(group)) { outline.add(outline.get(0)); - double[] x_coords = - outline.stream() + double[] x_coords + = outline.stream() .mapToDouble((point) -> (double) point.getX()) .toArray(); - double[] z_coords = - outline.stream() + double[] z_coords + = outline.stream() .mapToDouble((point) -> (double) point.getY()) .toArray(); - double[] y_coords = - outline.stream().mapToDouble((point) -> 320.0).toArray(); + double[] y_coords + = outline.stream().mapToDouble((point) -> 320.0).toArray(); - PolyLineMarker marker = - markerSet.createPolyLineMarker( + PolyLineMarker marker + = markerSet.createPolyLineMarker( UUID.randomUUID().toString(), "", false, @@ -153,7 +153,7 @@ private void generateMarkers() { marker.setLineStyle( marker.getLineWeight(), marker.getLineOpacity(), - faction.getColor().getColor()); + faction.getColorValue()); } } } @@ -165,11 +165,12 @@ private void updateFaction(Faction faction) { String info = getInfo(faction); for (Claim claim : faction.getClaims()) { - AreaMarker marker = markerSet.findAreaMarker(claim.getKey()); + AreaMarker marker = + markerSet.findAreaMarker(claim.level + '-' + claim.x + '-' + claim.z); - marker.setFillStyle(marker.getFillOpacity(), faction.getColor().getColor()); + marker.setFillStyle(marker.getFillOpacity(), faction.getColorValue()); marker.setLineStyle( - marker.getLineWeight(), marker.getLineOpacity(), faction.getColor().getColor()); + marker.getLineWeight(), marker.getLineOpacity(), faction.getColorValue()); marker.setDescription(info); } } @@ -245,8 +246,8 @@ private String getInfo(Faction faction) { public void reloadAll() { markerSet.deleteMarkerSet(); - markerSet = - markerApi.createMarkerSet( + markerSet + = markerApi.createMarkerSet( "dynmap-factions", "The Dynmap Factions integration", null, true); generateMarkers(); } diff --git a/src/main/java/io/icker/factions/util/PlaceholdersWrapper.java b/src/main/java/io/icker/factions/util/PlaceholdersWrapper.java index ca79a21b..500d642c 100644 --- a/src/main/java/io/icker/factions/util/PlaceholdersWrapper.java +++ b/src/main/java/io/icker/factions/util/PlaceholdersWrapper.java @@ -18,20 +18,22 @@ import java.util.function.Function; public class PlaceholdersWrapper { - private static final Component UNFORMATTED_NULL = - Component.translatable("factions.papi.factionless"); - private static final Component FORMATTED_NULL = - UNFORMATTED_NULL.copy().withStyle(ChatFormatting.DARK_GRAY); + + private static final Component UNFORMATTED_NULL + = Component.translatable("factions.papi.factionless"); + private static final Component FORMATTED_NULL + = UNFORMATTED_NULL.copy().withStyle(ChatFormatting.DARK_GRAY); private static void register(String identifier, Function handler) { Placeholders.registerServer( Identifier.fromNamespaceAndPath(FactionsMod.MODID, identifier), (ctx, argument) -> { - if (!ctx.hasPlayer()) + if (!ctx.hasPlayer()) { return PlaceholderResult.invalid( Localization.raw( "argument.entity.notfound.player", ServerLanguage.getLanguage(FactionsMod.CONFIG.LANGUAGE))); + } User member = User.get(ctx.player().getUUID()); return PlaceholderResult.value(handler.apply(member)); @@ -43,7 +45,9 @@ public static void init() { "name", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return FORMATTED_NULL; + if (faction == null) { + return FORMATTED_NULL; + } return Component.literal(faction.getName()) .withStyle(member.getFaction().getColor()); @@ -53,7 +57,9 @@ public static void init() { "colorless_name", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return FORMATTED_NULL; + if (faction == null) { + return FORMATTED_NULL; + } return Component.nullToEmpty(faction.getName()); }); @@ -61,8 +67,9 @@ public static void init() { register( "chat", (member) -> { - if (member.chat == User.ChatMode.GLOBAL || !member.isInFaction()) + if (member.chat == User.ChatMode.GLOBAL || !member.isInFaction()) { return Component.translatable("factions.papi.chat.global"); + } return Component.translatable("factions.papi.chat.faction"); }); @@ -70,7 +77,9 @@ public static void init() { register( "rank", (member) -> { - if (!member.isInFaction()) return FORMATTED_NULL; + if (!member.isInFaction()) { + return FORMATTED_NULL; + } return Component.nullToEmpty(member.getRankName()); }); @@ -78,16 +87,20 @@ public static void init() { register( "color", (member) -> { - if (!member.isInFaction()) return Component.nullToEmpty("reset"); + if (!member.isInFaction()) { + return Component.nullToEmpty("reset"); + } - return Component.nullToEmpty(member.getFaction().getColor().getName()); + return Component.nullToEmpty(member.getFaction().getColorName()); }); register( "description", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return FORMATTED_NULL; + if (faction == null) { + return FORMATTED_NULL; + } return Component.nullToEmpty(faction.getDescription()); }); @@ -96,7 +109,9 @@ public static void init() { "state", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return UNFORMATTED_NULL; + if (faction == null) { + return UNFORMATTED_NULL; + } return Component.nullToEmpty(String.valueOf(faction.isOpen())); }); @@ -105,7 +120,9 @@ public static void init() { "power", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return UNFORMATTED_NULL; + if (faction == null) { + return UNFORMATTED_NULL; + } return Component.nullToEmpty(String.valueOf(faction.getPower())); }); @@ -114,14 +131,16 @@ public static void init() { "power_formatted", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return FORMATTED_NULL; - - int red = - mapBoundRange( - faction.calculateMaxPower(), 0, 170, 255, faction.getPower()); - int green = - mapBoundRange( - 0, faction.calculateMaxPower(), 170, 255, faction.getPower()); + if (faction == null) { + return FORMATTED_NULL; + } + + int red + = mapBoundRange( + faction.calculateMaxPower(), 0, 170, 255, faction.getPower()); + int green + = mapBoundRange( + 0, faction.calculateMaxPower(), 170, 255, faction.getPower()); return Component.literal(String.valueOf(faction.getPower())) .setStyle(Style.EMPTY.withColor(rgbToInt(red, green, 170))); }); @@ -130,7 +149,9 @@ public static void init() { "max_power", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return UNFORMATTED_NULL; + if (faction == null) { + return UNFORMATTED_NULL; + } return Component.nullToEmpty(String.valueOf(faction.calculateMaxPower())); }); @@ -145,22 +166,26 @@ public static void init() { "required_power", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return UNFORMATTED_NULL; + if (faction == null) { + return UNFORMATTED_NULL; + } return Component.nullToEmpty( String.valueOf( - faction.getClaims().size() - * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT)); + faction.getClaimCount() + * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT)); }); register( "required_power_formatted", (member) -> { Faction faction = member.getFaction(); - if (faction == null) return FORMATTED_NULL; + if (faction == null) { + return FORMATTED_NULL; + } - int reqPower = - faction.getClaims().size() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; + int reqPower + = faction.getClaimCount() * FactionsMod.CONFIG.POWER.CLAIM_WEIGHT; int red = mapBoundRange(0, faction.getPower(), 85, 255, reqPower); return Component.literal(String.valueOf(reqPower)) .setStyle(Style.EMPTY.withColor(rgbToInt(red, 85, 85))); diff --git a/src/main/java/io/icker/factions/util/SquareMapWrapper.java b/src/main/java/io/icker/factions/util/SquareMapWrapper.java index fe3b2f02..c4135570 100644 --- a/src/main/java/io/icker/factions/util/SquareMapWrapper.java +++ b/src/main/java/io/icker/factions/util/SquareMapWrapper.java @@ -30,6 +30,7 @@ import java.util.stream.Collectors; public class SquareMapWrapper { + private HashMap layers = new HashMap<>(); private Squaremap api; @@ -72,35 +73,35 @@ private void generateMarkers() { } String info = getInfo(faction); - for (Map.Entry> entry : - ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { + for (Map.Entry> entry + : ClaimGrouper.separateClaimsByLevel(faction).entrySet()) { String level = entry.getKey(); - for (Map group : - ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { - List> outlines = - ClaimGrouper.convertLineSegmentsToOutlines(group); - List> points = - outlines.stream() + for (Map group + : ClaimGrouper.convertClaimsToLineSegmentGroups(entry.getValue())) { + List> outlines + = ClaimGrouper.convertLineSegmentsToOutlines(group); + List> points + = outlines.stream() .map( - (hole) -> - hole.stream() - .map( - (point) -> - Point.of( - point.getX(), - point.getY())) - .collect(Collectors.toList())) + (hole) + -> hole.stream() + .map( + (point) + -> Point.of( + point.getX(), + point.getY())) + .collect(Collectors.toList())) .collect(Collectors.toList()); SimpleLayerProvider layer = layers.get(level); if (layer == null) { - layer = - SimpleLayerProvider.builder("factions-" + level) + layer + = SimpleLayerProvider.builder("factions-" + level) .showControls(true) .build(); - MapWorld world = - api.getWorldIfEnabled(WorldIdentifier.parse(level)).orElse(null); + MapWorld world + = api.getWorldIfEnabled(WorldIdentifier.parse(level)).orElse(null); if (world != null) { world.layerRegistry() .register(Key.of("factions-" + level.replace(':', '-')), layer); @@ -109,16 +110,14 @@ private void generateMarkers() { layers.put(level, layer); } - Marker marker = - Marker.polygon(points.removeFirst(), points) + Marker marker + = Marker.polygon(points.removeFirst(), points) .markerOptions( MarkerOptions.builder() .fillColor( - new Color( - faction.getColor().getColor())) + new Color(faction.getColorValue())) .strokeColor( - new Color( - faction.getColor().getColor())) + new Color(faction.getColorValue())) .hoverTooltip(faction.getName()) .clickTooltip(info)); @@ -139,8 +138,8 @@ private void setHome(Faction faction, Home home) { SimpleLayerProvider layer = layers.get(home.level); if (layer == null) { - layer = - SimpleLayerProvider.builder("factions-" + home.level) + layer + = SimpleLayerProvider.builder("factions-" + home.level) .showControls(true) .build(); @@ -165,8 +164,8 @@ private void setHome(Faction faction, Home home) { Marker marker = layer.registeredMarkers().get(Key.of(faction.getID().toString() + "-home")); if (marker == null) { - Marker homeMarker = - Marker.icon(Point.of(home.x, home.z), Key.of("squaremap-spawn_icon"), 16) + Marker homeMarker + = Marker.icon(Point.of(home.x, home.z), Key.of("squaremap-spawn_icon"), 16) .markerOptions( MarkerOptions.builder() .clickTooltip(getInfo(faction)) diff --git a/src/main/java/io/icker/factions/util/WorldUtils.java b/src/main/java/io/icker/factions/util/WorldUtils.java index cf8254c7..37b537e1 100644 --- a/src/main/java/io/icker/factions/util/WorldUtils.java +++ b/src/main/java/io/icker/factions/util/WorldUtils.java @@ -5,16 +5,22 @@ import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; +import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import org.jetbrains.annotations.Nullable; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; public class WorldUtils { + private static final Map DIMENSION_STRINGS = new ConcurrentHashMap<>(); + public static MinecraftServer server; public static final Event ON_READY = @@ -39,6 +45,11 @@ public static boolean isReady() { return server != null; } + @Nullable + public static MinecraftServer getServer() { + return server; + } + public static boolean hasWorlds() { return !WorldUtils.server.levelKeys().isEmpty(); } @@ -48,6 +59,15 @@ public static boolean isValid(String level) { .anyMatch(key -> Objects.equals(key.identifier(), Identifier.parse(level))); } + public static ChunkPos getChunkPos(BlockPos position) { + return new ChunkPos(position.getX() >> 4, position.getZ() >> 4); + } + + public static String dimensionString(Level world) { + return DIMENSION_STRINGS.computeIfAbsent( + world.dimension().identifier(), Identifier::toString); + } + @Nullable public static ServerLevel getWorld(String level) { Optional> key = diff --git a/src/main/resources/data/factions/lang/en_us.json b/src/main/resources/data/factions/lang/en_us.json index fab12c2f..5b9715a5 100644 --- a/src/main/resources/data/factions/lang/en_us.json +++ b/src/main/resources/data/factions/lang/en_us.json @@ -270,7 +270,6 @@ "factions.command.settings.sound.faction": "Faction", "factions.command.settings.sound.none": "None", "factions.command.settings.sound.warnings": "Warnings", - "factions.command.settings.radar": "Successfully toggled claim radar", "factions.chat.in_faction_symbol": "F", "factions.chat.faction_chat_when_not_in_faction": "You can't send a message to faction chat if you aren't in a faction.", @@ -290,6 +289,7 @@ "factions.interactions.name.attack_entities": "attack entities", "factions.interactions.name.use_entities": "use entities", "factions.interactions.name.use_inventory": "use inventory", + "factions.interactions.name.restricted_item": "use that item", "factions.level.minecraft:overworld": "Overworld", "factions.level.minecraft:the_nether": "The Nether", @@ -297,7 +297,6 @@ "factions.events.autoclaim.fail": "Not enough faction power to claim chunk, autoclaim toggled off", "factions.events.autoclaim.success": "Chunk (%d, %d) claimed by %s", - "factions.radar.wilderness": "Wilderness", "factions.factionless": "[FACTIONLESS]", "factions.default_description": "No description set", @@ -305,5 +304,70 @@ "factions.papi.chat.global": "Global Chat", "factions.papi.chat.faction": "Faction Chat", - "factions.papi.factionless": "FACTIONLESS" + "factions.papi.factionless": "FACTIONLESS", + "factions.command.grant.success.break": "Granted %s break blocks to %s", + "factions.command.grant.success.place": "Granted %s place blocks to %s", + "factions.command.grant.fail.already_member": "That player is already a member of your faction", + "factions.command.grant.fail.player_not_found": "Player not found or not online", + "factions.command.grant.fail.max_exceeded": "Grant would exceed the maximum allowed quota", + "factions.command.grant.revoke.success": "Guest grant revoked successfully", + "factions.command.grant.revoke.fail.no_grant": "No grant exists for that player", + "factions.command.grant.list.header": "Guest Grants:", + "factions.command.grant.list.entry": "%s - Break: %s, Place: %s", + "factions.announcer.wilderness": "Wilderness", + "factions.command.tp.request.sent": "Teleport request sent to %s", + "factions.command.tp.request.received": "%s wants to teleport to you. Use /f tp accept or /f tp deny (expires in %s seconds)", + "factions.command.tp.request.hover": "Click to accept this teleport request", + "factions.command.tp.fail.offline": "That player is not online", + "factions.command.tp.fail.self": "Cannot teleport to yourself", + "factions.command.tp.fail.not_member": "That player is not in your faction", + "factions.command.tp.fail.cooldown": "Teleport is on cooldown, please wait %s seconds", + "factions.command.tp.fail.combat": "Cannot teleport while in combat", + "factions.command.tp.accept.success.requester": "Teleporting to %s", + "factions.command.tp.accept.success.target": "%s teleported to you", + "factions.command.tp.accept.fail.no_request": "You have no pending teleport request", + "factions.command.tp.accept.fail.expired": "That teleport request has expired", + "factions.command.tp.deny.success": "Teleport request denied", + "factions.command.tp.deny.notified": "%s denied your teleport request", + "factions.command.tp.deny.fail.no_request": "You have no pending teleport request", + "factions.command.gather.fail.no_world": "Cannot find dimension", + "factions.command.gather.success": "Teleported to the gathering hall", + "factions.command.trade.request.sent": "Trade meeting request sent to %s", + "factions.command.trade.request.received": "%s wants to trade with your faction. Use /f trade y or /f trade n (expires in %s seconds)", + "factions.command.trade.request.hover": "Click to accept this trade request", + "factions.command.trade.fail.nonexistent_faction": "Cannot send trade request to a faction that doesn't exist", + "factions.command.trade.fail.own_faction": "Cannot send a trade request to your own faction", + "factions.command.trade.fail.not_member": "You must be in a faction to use this command", + "factions.command.trade.accept.fail.no_request": "Your faction has no pending trade request", + "factions.command.trade.accept.fail.expired": "That trade request has expired", + "factions.command.trade.accept.fail.requester_offline": "The requester is no longer online", + "factions.command.trade.accept.success": "Trade accepted — opening trade interface", + "factions.command.trade.deny.success": "Trade request denied", + "factions.command.trade.deny.fail.no_request": "Your faction has no pending trade request", + "factions.command.trade.deny.notified": "%s denied your trade request", + "factions.command.trade.stage.fail.already_trading": "One of you is already in a trade session", + "factions.command.trade.stage.fail.no_world": "Trade stage dimension not found", + + "factions.command.rules.elytra.disabled": "Elytra flight is now blocked in your faction claims", + "factions.command.rules.elytra.enabled": "Elytra flight is now allowed in your faction claims", + + "factions.rules.elytra.blocked": "Elytra flight is disabled in this claim", + + "factions.gui.trade.placement.cancel": "Cancel trade", + "factions.gui.trade.placement.confirm": "Confirm your offer", + "factions.gui.trade.placement.title": "Trade — place your offer", + "factions.gui.trade.reveal.title": "Trade — review offer", + + "factions.trade.accept": "Accept trade", + "factions.trade.cancelled.declined": "Trade declined", + "factions.trade.cancelled.disconnected": "Trade cancelled — player disconnected", + "factions.trade.cancelled.faction_disbanded": "Trade cancelled — faction disbanded", + "factions.trade.cancelled.gui_closed": "Trade cancelled", + "factions.trade.cancelled.inventory_full": "Trade cancelled — inventory full", + "factions.trade.cancelled.player_cancelled": "Trade cancelled", + "factions.trade.cancelled.server_stopping": "Trade cancelled — server stopping", + "factions.trade.decline": "Decline", + "factions.trade.filler": " ", + "factions.trade.negotiate": "Renegotiate", + "factions.trade.result.completed": "Trade completed" } diff --git a/src/main/resources/data/factions/lang/ru_ru.json b/src/main/resources/data/factions/lang/ru_ru.json index 6100b6d0..3215710f 100644 --- a/src/main/resources/data/factions/lang/ru_ru.json +++ b/src/main/resources/data/factions/lang/ru_ru.json @@ -266,7 +266,6 @@ "factions.command.settings.sound.faction": "Фракция", "factions.command.settings.sound.none": "Никакие", "factions.command.settings.sound.warnings": "Предупреждения", - "factions.command.settings.radar": "Успешно переключён радар захватов", "factions.chat.in_faction_symbol": "F", "factions.chat.faction_chat_when_not_in_faction": "Нельзя отправить сообщение в чат фракции, если вы не состоите в фракции.", @@ -293,7 +292,6 @@ "factions.events.autoclaim.fail": "Недостаточно мощи фракции, чтобы захватить чанк. Автозахват выключен", "factions.events.autoclaim.success": "Чанк (%d, %d) захвачен %s", - "factions.radar.wilderness": "Незахвачено", "factions.factionless": "[БЕЗ ФРАКЦИИ]", "factions.default_description": "Нет описания", @@ -301,5 +299,45 @@ "factions.papi.chat.global": "Глобальный чат", "factions.papi.chat.faction": "Чат фракции", - "factions.papi.factionless": "БЕЗ ФРАКЦИИ" + "factions.papi.factionless": "БЕЗ ФРАКЦИИ", + "factions.command.gather.fail.no_world": "Cannot find dimension", + "factions.command.gather.success": "Teleported to the gathering hall", + "factions.command.trade.request.sent": "Trade meeting request sent to %s", + "factions.command.trade.request.received": "%s хочет торговать с вашей фракцией. Используйте /f trade y или /f trade n (истекает через %s секунд)", + "factions.command.trade.request.hover": "Click to accept this trade request", + "factions.command.trade.fail.nonexistent_faction": "Cannot send trade request to a faction that doesn't exist", + "factions.command.trade.fail.own_faction": "Cannot send a trade request to your own faction", + "factions.command.trade.fail.not_member": "You must be in a faction to use this command", + "factions.command.trade.accept.fail.no_request": "Your faction has no pending trade request", + "factions.command.trade.accept.fail.expired": "That trade request has expired", + "factions.command.trade.accept.fail.requester_offline": "Инициатор обмена уже не в сети", + "factions.command.trade.accept.success": "Обмен принят — открывается торговый интерфейс", + "factions.command.trade.deny.success": "Trade request denied", + "factions.command.trade.deny.fail.no_request": "Your faction has no pending trade request", + "factions.command.trade.deny.notified": "%s denied your trade request", + "factions.command.trade.stage.fail.already_trading": "Один из вас уже участвует в обмене", + "factions.command.trade.stage.fail.no_world": "Измерение торговой площадки не найдено", + + "factions.command.rules.elytra.disabled": "Полёт на элитрах теперь запрещён в клеймах вашей фракции", + "factions.command.rules.elytra.enabled": "Полёт на элитрах теперь разрешён в клеймах вашей фракции", + + "factions.rules.elytra.blocked": "Полёт на элитрах отключён в этом клейме", + + "factions.gui.trade.placement.cancel": "Отменить сделку", + "factions.gui.trade.placement.confirm": "Подтвердить предложение", + "factions.gui.trade.placement.title": "Торговля — положите своё предложение", + "factions.gui.trade.reveal.title": "Торговля — просмотрите предложение", + + "factions.trade.accept": "Принять сделку", + "factions.trade.cancelled.declined": "Сделка отклонена", + "factions.trade.cancelled.disconnected": "Сделка отменена — игрок отключился", + "factions.trade.cancelled.faction_disbanded": "Сделка отменена — фракция распущена", + "factions.trade.cancelled.gui_closed": "Сделка отменена", + "factions.trade.cancelled.inventory_full": "Сделка отменена — инвентарь полон", + "factions.trade.cancelled.player_cancelled": "Сделка отменена", + "factions.trade.cancelled.server_stopping": "Сделка отменена — сервер останавливается", + "factions.trade.decline": "Отклонить", + "factions.trade.filler": " ", + "factions.trade.negotiate": "Пересогласовать", + "factions.trade.result.completed": "Сделка завершена" } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 987d2ee4..b429f1d0 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -8,11 +8,13 @@ "authors": [ "ickerio", "BlueZeeKing", - "PyPylia" + "PyPylia", + "joshes14" ], "contact": { - "homepage": "https://icker.io/factions/", - "sources": "https://github.com/ickerio/factions" + "homepage": "https://github.com/joshes14/factions", + "sources": "https://github.com/joshes14/factions", + "issues": "https://github.com/joshes14/factions/issues" }, "license": "MIT", @@ -21,17 +23,24 @@ "environment": "*", "entrypoints": { "main": [ - "io.icker.factions.FactionsMod" + "io.icker.factions.FactionsMod" + ], + "client": [ + "io.icker.factions.client.FactionsClientMod" ] }, "mixins": [ - "factions.mixins.json" + "factions.mixins.json", + { + "config": "factions.client.mixins.json", + "environment": "client" + } ], "depends": { "fabricloader": ">=0.13.3", - "minecraft": "~26.1", - "java": ">=17", + "minecraft": "~26.2", + "java": ">=25", "fabric-api": "*" }, "suggests": { diff --git a/src/test/java/io/icker/factions/api/persistents/FactionClaimCountsTest.java b/src/test/java/io/icker/factions/api/persistents/FactionClaimCountsTest.java new file mode 100644 index 00000000..26417238 --- /dev/null +++ b/src/test/java/io/icker/factions/api/persistents/FactionClaimCountsTest.java @@ -0,0 +1,44 @@ +package io.icker.factions.api.persistents; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class FactionClaimCountsTest { + @Test + void claimCountTracksAddsAndRemovalsWithoutWorldAccess() { + UUID factionId = UUID.randomUUID(); + FactionClaimCounts counts = new FactionClaimCounts(); + + counts.add(factionId); + assertEquals(1, counts.get(factionId)); + + counts.remove(factionId); + assertEquals(0, counts.get(factionId)); + } + + @Test + void replacingAClaimKeepsFactionCountsAccurate() { + UUID firstFactionId = UUID.randomUUID(); + UUID secondFactionId = UUID.randomUUID(); + FactionClaimCounts counts = new FactionClaimCounts(); + + counts.add(firstFactionId); + counts.replace(firstFactionId, secondFactionId); + + assertEquals(0, counts.get(firstFactionId)); + assertEquals(1, counts.get(secondFactionId)); + } + + @Test + void replacingAClaimForTheSameFactionKeepsItsCount() { + UUID factionId = UUID.randomUUID(); + FactionClaimCounts counts = new FactionClaimCounts(); + + counts.add(factionId); + counts.replace(factionId, factionId); + + assertEquals(1, counts.get(factionId)); + } +} diff --git a/src/test/java/io/icker/factions/api/persistents/FactionRulesElytraTest.java b/src/test/java/io/icker/factions/api/persistents/FactionRulesElytraTest.java new file mode 100644 index 00000000..214d6c22 --- /dev/null +++ b/src/test/java/io/icker/factions/api/persistents/FactionRulesElytraTest.java @@ -0,0 +1,81 @@ +package io.icker.factions.api.persistents; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Faction.Rules}. + * + *

{@code Faction.Rules} is a plain nested POJO — no persistence side effects on construction + * — and holds the per-faction toggles that the interaction listeners consult. This suite pins + * the elytra rule's default value and its mutability, which is the pure-Java half of the + * "elytra restricted in own territory" feature added in 3.3. + * + *

The {@link Faction#isElytraAllowed()} accessor itself is trivial delegation + * ({@code return rules != null && rules.elytra}) but constructing a {@link Faction} instance + * loads the outer class's static store from disk via {@code Database.load(...)}, which is only + * available at runtime under FabricLoader. That is covered by manual F3 QA — this suite pins + * the POJO invariant that the accessor depends on. + */ +class FactionRulesElytraTest { + + @Test + void rules_elytra_defaultTrue() { + Faction.Rules rules = new Faction.Rules(); + + assertTrue(rules.elytra, + "elytra must default to true — the 3.3 rule is opt-out, not opt-in, so " + + "an existing faction with no persisted Rules block continues to " + + "allow elytra flight until the leader disables it"); + } + + @Test + void rules_elytra_isMutable() { + Faction.Rules rules = new Faction.Rules(); + + rules.elytra = false; + + assertFalse(rules.elytra, + "the field is deliberately public so the /f rule set command can flip it " + + "without a setter — pinning that access remains open"); + } + + @Test + void rules_elytra_isMutableBackToTrue() { + Faction.Rules rules = new Faction.Rules(); + rules.elytra = false; + + rules.elytra = true; + + assertTrue(rules.elytra, + "flipping back must be symmetric — the leader can re-enable elytra later"); + } + + @Test + void rules_defaultConstructor_producesNonNullInstance() { + Faction.Rules rules = new Faction.Rules(); + + assertNotNull(rules, + "the no-arg constructor must produce a live Rules instance — this is what " + + "Faction uses when initialising the {@code rules} field on new " + + "factions and when reading legacy on-disk data that has no Rules " + + "block"); + } + + @Test + void rules_independentInstances_doNotShareState() { + Faction.Rules a = new Faction.Rules(); + Faction.Rules b = new Faction.Rules(); + + a.elytra = false; + + assertTrue(b.elytra, + "each Rules instance owns its own state — flipping one MUST NOT bleed into " + + "another (guards against an accidental static field regression)"); + assertFalse(a.elytra, + "the flip on `a` must have persisted on `a`"); + } +} diff --git a/src/test/java/io/icker/factions/api/persistents/GuestGrantTest.java b/src/test/java/io/icker/factions/api/persistents/GuestGrantTest.java new file mode 100644 index 00000000..3639d848 --- /dev/null +++ b/src/test/java/io/icker/factions/api/persistents/GuestGrantTest.java @@ -0,0 +1,103 @@ +package io.icker.factions.api.persistents; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +class GuestGrantTest { + private static int decrementIfAvailable(int remaining) { + return remaining > 0 ? remaining - 1 : remaining; + } + + private record TestGrant(UUID factionID, UUID playerID) { + String key() { + return factionID + "-" + playerID; + } + } + + private static List getByFaction( + HashMap store, UUID factionID) { + return store.values().stream().filter(g -> g.factionID().equals(factionID)).toList(); + } + + @Test + void breakQuotaDecrementsByOneWhenAvailable() { + assertEquals(2, decrementIfAvailable(3)); + } + + @Test + void breakQuotaDoesNotBecomeNegative() { + assertEquals(0, decrementIfAvailable(0)); + } + + @Test + void grantIsRevokedWhenBothQuotasReachZero() { + int breakRemaining = decrementIfAvailable(1); + int placeRemaining = 0; + + assertTrue(breakRemaining == 0 && placeRemaining == 0); + } + + @Test + void grantRemainsWhenPlaceQuotaIsAvailable() { + int breakRemaining = decrementIfAvailable(1); + int placeRemaining = 2; + + assertFalse(breakRemaining == 0 && placeRemaining == 0); + } + + @Test + void getByFactionReturnsOnlyGrantsForRequestedFaction() { + UUID factionA = UUID.randomUUID(); + UUID factionB = UUID.randomUUID(); + TestGrant a1 = new TestGrant(factionA, UUID.randomUUID()); + TestGrant a2 = new TestGrant(factionA, UUID.randomUUID()); + TestGrant b1 = new TestGrant(factionB, UUID.randomUUID()); + + HashMap store = new HashMap<>(); + store.put(a1.key(), a1); + store.put(a2.key(), a2); + store.put(b1.key(), b1); + + List forA = getByFaction(store, factionA); + + assertEquals(2, forA.size(), "factionA should have exactly its two grants"); + assertTrue(forA.contains(a1), "factionA's a1 must be returned"); + assertTrue(forA.contains(a2), "factionA's a2 must be returned"); + assertFalse(forA.contains(b1), "factionB's grant must NOT be returned for factionA"); + } + + @Test + void removingEveryGrantForOneFactionLeavesTheOtherFactionsGrantsIntact() { + UUID disbanded = UUID.randomUUID(); + UUID survivor = UUID.randomUUID(); + TestGrant disbandedGrant1 = new TestGrant(disbanded, UUID.randomUUID()); + TestGrant disbandedGrant2 = new TestGrant(disbanded, UUID.randomUUID()); + TestGrant survivorGrant = new TestGrant(survivor, UUID.randomUUID()); + + HashMap store = new HashMap<>(); + store.put(disbandedGrant1.key(), disbandedGrant1); + store.put(disbandedGrant2.key(), disbandedGrant2); + store.put(survivorGrant.key(), survivorGrant); + + for (TestGrant grant : getByFaction(store, disbanded)) { + store.remove(grant.key()); + } + + assertTrue(getByFaction(store, disbanded).isEmpty(), + "disbanded faction's grants must be gone"); + assertEquals(1, getByFaction(store, survivor).size(), + "survivor faction's grants must be untouched"); + assertTrue(getByFaction(store, survivor).contains(survivorGrant), + "survivor's exact grant must remain"); + assertEquals(1, store.size(), + "store must contain only the survivor's grant after disband cleanup"); + } + +} diff --git a/src/test/java/io/icker/factions/command/ClaimExclusivityTest.java b/src/test/java/io/icker/factions/command/ClaimExclusivityTest.java new file mode 100644 index 00000000..5a19752c --- /dev/null +++ b/src/test/java/io/icker/factions/command/ClaimExclusivityTest.java @@ -0,0 +1,44 @@ +package io.icker.factions.command; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ClaimExclusivityTest { + + @Test + void nonBypassUser_cannotClaimForeignChunk() { + boolean userBypass = false; + boolean claimBelongsToActor = false; + + boolean shouldReject = !userBypass && !claimBelongsToActor; + assertTrue(shouldReject, "Non-bypass user must be rejected when claiming a foreign chunk"); + } + + @Test + void bypassUser_canClaimForeignChunk() { + boolean userBypass = true; + boolean claimBelongsToActor = false; + + boolean shouldReject = !userBypass && !claimBelongsToActor; + assertFalse(shouldReject, "Bypass user must NOT be rejected when claiming a foreign chunk"); + } + + @Test + void nonBypassUser_canClaimOwnFactionChunk() { + boolean userBypass = false; + boolean claimBelongsToActor = true; + + boolean shouldReject = !userBypass && !claimBelongsToActor; + assertFalse(shouldReject, "Non-bypass user must NOT be rejected for their own faction's chunk"); + } + + @Test + void unclaimedChunk_neverRejected() { + boolean existingClaim = false; + + boolean shouldCheckExclusivity = existingClaim; + assertFalse(shouldCheckExclusivity, "Unclaimed chunks skip the exclusivity check entirely"); + } +} diff --git a/src/test/java/io/icker/factions/command/ClaimPowerTest.java b/src/test/java/io/icker/factions/command/ClaimPowerTest.java new file mode 100644 index 00000000..c62ac0b2 --- /dev/null +++ b/src/test/java/io/icker/factions/command/ClaimPowerTest.java @@ -0,0 +1,172 @@ +package io.icker.factions.command; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Mirrors the pure arithmetic of the multi-chunk claim power rule that lives inside + * {@code ClaimCommand.addForced}. The command itself needs a live Minecraft server, so + * the assertions here focus on the affordability inequality — the same one the + * {@link ClaimExclusivityTest} and {@code GuestGrantTest} suites replicate locally + * to sidestep the FabricLoader-only runtime. + * + *

Rule (as of 3.1.1): + *

+ *   (claimCount + newChunks) * claimWeight <= maxPower
+ * 
+ * where {@code newChunks} counts ONLY chunks where {@code existingClaim == null} — + * chunks already owned by the acting faction are re-claimed harmlessly and do NOT + * contribute to the power cost. + */ +class ClaimPowerTest { + + /** The new (3.1.1) affordability rule that gates {@code /f claim add }. */ + private static boolean canAffordNewRule( + int claimCount, int newChunks, int claimWeight, int maxPower) { + int required = (claimCount + newChunks) * claimWeight; + return maxPower >= required; + } + + /** + * The old (3.1.0) rule that only charged for a single chunk regardless of the + * requested size. Kept solely to prove the exploit case now fails. + */ + private static boolean canAffordOldRule(int claimCount, int claimWeight, int maxPower) { + int required = (claimCount + 1) * claimWeight; + return maxPower >= required; + } + + /** {@code (2 * size - 1)^2} — the chunk-square footprint of {@code /f claim add }. */ + private static int squareFootprint(int size) { + int side = 2 * size - 1; + return side * side; + } + + @Test + void sufficientPowerAllowsSingleClaim() { + // Given a faction with plenty of headroom + int claimCount = 3; + int newChunks = 1; + int weight = 5; + int maxPower = 100; + + // When the new rule is evaluated + boolean afford = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then the claim is allowed: (3 + 1) * 5 = 20 <= 100 + assertTrue(afford, "20 required vs 100 max should be affordable"); + } + + @Test + void insufficientPowerRejectsMultiChunkClaim() { + // Given a broke faction attempting `/f claim add 3` on entirely new land + int claimCount = 0; + int newChunks = squareFootprint(3); // 5x5 = 25 + int weight = 5; + int maxPower = 40; + + // When the new rule evaluates it + boolean afford = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then the claim is rejected: (0 + 25) * 5 = 125 > 40 + assertFalse(afford, "125 required vs 40 max should NOT be affordable"); + } + + @Test + void multiChunkExploitOldRulePassedNewRuleRejects() { + // Given the exact exploit case from the bug report: /f claim add 7 on empty land + // with a tiny amount of power. `size=7` => (2*7-1)^2 = 169 chunks. + int claimCount = 0; + int newChunks = squareFootprint(7); // 13x13 = 169 + int weight = 1; + int maxPower = 10; + + // When the old rule is evaluated + boolean oldRuleAllowed = canAffordOldRule(claimCount, weight, maxPower); + + // When the new rule is evaluated + boolean newRuleAllowed = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then the old rule (buggy) passed but the new rule (fixed) blocks it + assertTrue(oldRuleAllowed, "old rule (buggy): (0+1)*1 = 1 <= 10 — WOULD have passed"); + assertFalse(newRuleAllowed, "new rule (fixed): (0+169)*1 = 169 > 10 — MUST fail"); + } + + @Test + void alreadyOwnedChunksDoNotCountTowardCost() { + // Given a 3x3 (size=2) claim where 5 of the 9 chunks are already owned by the actor's + // faction — those must be re-claimed harmlessly and contribute 0 to the power cost. + int claimCount = 5; // the pre-owned chunks are counted in the current tally + int totalChunksInSquare = squareFootprint(2); // 3x3 = 9 + int reOwnedChunks = 5; + int newChunks = totalChunksInSquare - reOwnedChunks; // 4 genuinely new + int weight = 5; + int maxPower = 50; + + // When the new rule counts only new chunks + boolean afford = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then the cost is (5 + 4) * 5 = 45 <= 50 — allowed + assertTrue(afford, "already-owned chunks must NOT be double-charged"); + + // Sanity check: if we (wrongly) counted the re-owned chunks too, we would fail + boolean wouldFailIfWeDoubleCharged = + !canAffordNewRule(claimCount, totalChunksInSquare, weight, maxPower); + assertTrue( + wouldFailIfWeDoubleCharged, + "the fixture MUST distinguish re-owned from new — (5+9)*5=70>50"); + } + + @Test + void singleClaimBehavesIdenticallyToOldRule() { + // Given a size=1 add (`/f claim add`), which claims exactly one chunk when it's new + int claimCount = 4; + int newChunks = 1; + int weight = 5; + + // When both rules evaluate a range of power budgets around the threshold + // Threshold: (4 + 1) * 5 = 25. Old rule uses the same formula. + for (int maxPower : new int[] {0, 10, 24, 25, 26, 100}) { + boolean newRule = canAffordNewRule(claimCount, newChunks, weight, maxPower); + boolean oldRule = canAffordOldRule(claimCount, weight, maxPower); + + // Then single-chunk claims must give the same verdict — no regression + assertTrue( + newRule == oldRule, + "size=1 must match old rule at maxPower=" + maxPower); + } + } + + @Test + void singleClaimOnAlreadyOwnedChunkCostsZeroNewChunks() { + // Given `/f claim add` on the actor's own already-owned chunk (an admin re-claim + // via bypass, for instance) — newChunks should be 0. + int claimCount = 10; + int newChunks = 0; + int weight = 5; + int maxPower = 50; // exactly at the current claim tally cost + + // When the new rule evaluates it + boolean afford = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then (10 + 0) * 5 = 50 <= 50 — allowed, and no additional power is charged + assertTrue(afford, "re-claiming with 0 new chunks charges no additional power"); + } + + @Test + void exactThresholdIsAffordable() { + // Given a claim that would consume power exactly equal to the maximum + int claimCount = 2; + int newChunks = 3; + int weight = 4; + int maxPower = 20; // (2 + 3) * 4 = 20 + + // When the new rule evaluates it + boolean afford = canAffordNewRule(claimCount, newChunks, weight, maxPower); + + // Then required == max is allowed (not strictly greater than) + assertTrue(afford, "required == max must be affordable, not rejected"); + } +} diff --git a/src/test/java/io/icker/factions/config/TradeStageConfigLoadTest.java b/src/test/java/io/icker/factions/config/TradeStageConfigLoadTest.java new file mode 100644 index 00000000..b6d763bf --- /dev/null +++ b/src/test/java/io/icker/factions/config/TradeStageConfigLoadTest.java @@ -0,0 +1,202 @@ +package io.icker.factions.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TradeStageConfig} defaults and Gson round-trip. + * + *

The stage coordinates and level are calibrated to the trade stage build supplied by + * the project owner. This suite pins: + *

    + *
  • the exact defaults — a regression in any of these silently teleports every + * trade participant into the wrong world or off the platform;
  • + *
  • the {@link com.google.gson.annotations.SerializedName} bindings — Gson names in + * the on-disk {@code factions.json} MUST match {@code camelCase} keys or existing + * user configs stop deserialising;
  • + *
  • the {@code enabled} default — 3.3 ships {@code /f trade} enabled by default.
  • + *
+ */ +class TradeStageConfigLoadTest { + + private static Gson gson() { + return new GsonBuilder().create(); + } + + @Test + void defaults_requesterCoordinatesMatchSuppliedStage() { + TradeStageConfig cfg = new TradeStageConfig(); + + assertEquals(1.050, cfg.REQUESTER_X, 0.001, + "requester X must match the stage-supplied spawn point"); + assertEquals(160.125, cfg.REQUESTER_Y, 0.001, + "requester Y must match the stage-supplied spawn point"); + assertEquals(-27.500, cfg.REQUESTER_Z, 0.001, + "requester Z must match the stage-supplied spawn point"); + assertEquals(89.7f, cfg.REQUESTER_YAW, 0.01f, + "requester yaw must face the recipient across the stage"); + assertEquals(3.2f, cfg.REQUESTER_PITCH, 0.01f, + "requester pitch must match the stage-supplied camera angle"); + } + + @Test + void defaults_recipientCoordinatesMatchSuppliedStage() { + TradeStageConfig cfg = new TradeStageConfig(); + + assertEquals(-2.050, cfg.RECIPIENT_X, 0.001, + "recipient X must match the stage-supplied spawn point"); + assertEquals(160.125, cfg.RECIPIENT_Y, 0.001, + "recipient Y must match the stage-supplied spawn point"); + assertEquals(-27.482, cfg.RECIPIENT_Z, 0.001, + "recipient Z must match the stage-supplied spawn point"); + assertEquals(-90.9f, cfg.RECIPIENT_YAW, 0.01f, + "recipient yaw must face the requester across the stage"); + assertEquals(3.2f, cfg.RECIPIENT_PITCH, 0.01f, + "recipient pitch must match the stage-supplied camera angle"); + } + + @Test + void defaults_levelIsOverworld() { + TradeStageConfig cfg = new TradeStageConfig(); + + assertEquals("minecraft:overworld", cfg.LEVEL, + "the stage lives in the overworld — changing this silently breaks every " + + "trade because the stage build only exists in that dimension"); + } + + @Test + void defaults_enabledIsTrue() { + TradeStageConfig cfg = new TradeStageConfig(); + + assertTrue(cfg.ENABLED, + "3.3 ships /f trade enabled by default; only flip to false in a hotfix"); + } + + @Test + void gsonRoundTrip_preservesAllFields() { + TradeStageConfig original = new TradeStageConfig(); + original.ENABLED = false; + original.REQUESTER_X = 12.5; + original.REQUESTER_Y = 200.0; + original.REQUESTER_Z = -100.25; + original.REQUESTER_YAW = 45.0f; + original.REQUESTER_PITCH = -10.0f; + original.RECIPIENT_X = -12.5; + original.RECIPIENT_Y = 200.0; + original.RECIPIENT_Z = -100.75; + original.RECIPIENT_YAW = -135.0f; + original.RECIPIENT_PITCH = -10.0f; + original.LEVEL = "minecraft:the_nether"; + + String json = gson().toJson(original); + TradeStageConfig loaded = gson().fromJson(json, TradeStageConfig.class); + + assertNotNull(loaded, "Gson must decode a non-null instance"); + assertEquals(original.ENABLED, loaded.ENABLED); + assertEquals(original.REQUESTER_X, loaded.REQUESTER_X, 0.0001); + assertEquals(original.REQUESTER_Y, loaded.REQUESTER_Y, 0.0001); + assertEquals(original.REQUESTER_Z, loaded.REQUESTER_Z, 0.0001); + assertEquals(original.REQUESTER_YAW, loaded.REQUESTER_YAW, 0.0001f); + assertEquals(original.REQUESTER_PITCH, loaded.REQUESTER_PITCH, 0.0001f); + assertEquals(original.RECIPIENT_X, loaded.RECIPIENT_X, 0.0001); + assertEquals(original.RECIPIENT_Y, loaded.RECIPIENT_Y, 0.0001); + assertEquals(original.RECIPIENT_Z, loaded.RECIPIENT_Z, 0.0001); + assertEquals(original.RECIPIENT_YAW, loaded.RECIPIENT_YAW, 0.0001f); + assertEquals(original.RECIPIENT_PITCH, loaded.RECIPIENT_PITCH, 0.0001f); + assertEquals(original.LEVEL, loaded.LEVEL); + } + + @Test + void gsonRoundTrip_defaultsSurviveEncoding() { + TradeStageConfig original = new TradeStageConfig(); + + String json = gson().toJson(original); + TradeStageConfig loaded = gson().fromJson(json, TradeStageConfig.class); + + assertEquals(original.ENABLED, loaded.ENABLED); + assertEquals(original.REQUESTER_X, loaded.REQUESTER_X, 0.0001); + assertEquals(original.REQUESTER_Y, loaded.REQUESTER_Y, 0.0001); + assertEquals(original.REQUESTER_Z, loaded.REQUESTER_Z, 0.0001); + assertEquals(original.REQUESTER_YAW, loaded.REQUESTER_YAW, 0.0001f); + assertEquals(original.REQUESTER_PITCH, loaded.REQUESTER_PITCH, 0.0001f); + assertEquals(original.RECIPIENT_X, loaded.RECIPIENT_X, 0.0001); + assertEquals(original.RECIPIENT_Y, loaded.RECIPIENT_Y, 0.0001); + assertEquals(original.RECIPIENT_Z, loaded.RECIPIENT_Z, 0.0001); + assertEquals(original.RECIPIENT_YAW, loaded.RECIPIENT_YAW, 0.0001f); + assertEquals(original.RECIPIENT_PITCH, loaded.RECIPIENT_PITCH, 0.0001f); + assertEquals(original.LEVEL, loaded.LEVEL); + } + + @Test + void gsonEmitsExpectedCamelCaseKeys() { + TradeStageConfig cfg = new TradeStageConfig(); + + String json = gson().toJson(cfg); + + assertTrue(json.contains("\"enabled\""), + "SerializedName(\"enabled\") must survive — user configs use this key"); + assertTrue(json.contains("\"requesterX\""), + "SerializedName(\"requesterX\") must survive — user configs use this key"); + assertTrue(json.contains("\"requesterY\""), + "SerializedName(\"requesterY\") must survive — user configs use this key"); + assertTrue(json.contains("\"requesterZ\""), + "SerializedName(\"requesterZ\") must survive — user configs use this key"); + assertTrue(json.contains("\"requesterYaw\""), + "SerializedName(\"requesterYaw\") must survive — user configs use this key"); + assertTrue(json.contains("\"requesterPitch\""), + "SerializedName(\"requesterPitch\") must survive — user configs use this key"); + assertTrue(json.contains("\"recipientX\""), + "SerializedName(\"recipientX\") must survive — user configs use this key"); + assertTrue(json.contains("\"recipientY\""), + "SerializedName(\"recipientY\") must survive — user configs use this key"); + assertTrue(json.contains("\"recipientZ\""), + "SerializedName(\"recipientZ\") must survive — user configs use this key"); + assertTrue(json.contains("\"recipientYaw\""), + "SerializedName(\"recipientYaw\") must survive — user configs use this key"); + assertTrue(json.contains("\"recipientPitch\""), + "SerializedName(\"recipientPitch\") must survive — user configs use this key"); + assertTrue(json.contains("\"level\""), + "SerializedName(\"level\") must survive — user configs use this key"); + } + + @Test + void gsonDeserialisesFromExplicitCamelCaseJson() { + String json = + "{" + + "\"enabled\":true," + + "\"requesterX\":1.050," + + "\"requesterY\":160.125," + + "\"requesterZ\":-27.500," + + "\"requesterYaw\":89.7," + + "\"requesterPitch\":3.2," + + "\"recipientX\":-2.050," + + "\"recipientY\":160.125," + + "\"recipientZ\":-27.482," + + "\"recipientYaw\":-90.9," + + "\"recipientPitch\":3.2," + + "\"level\":\"minecraft:overworld\"" + + "}"; + + TradeStageConfig cfg = gson().fromJson(json, TradeStageConfig.class); + + assertNotNull(cfg); + assertTrue(cfg.ENABLED); + assertEquals(1.050, cfg.REQUESTER_X, 0.001); + assertEquals(160.125, cfg.REQUESTER_Y, 0.001); + assertEquals(-27.500, cfg.REQUESTER_Z, 0.001); + assertEquals(89.7f, cfg.REQUESTER_YAW, 0.01f); + assertEquals(3.2f, cfg.REQUESTER_PITCH, 0.01f); + assertEquals(-2.050, cfg.RECIPIENT_X, 0.001); + assertEquals(160.125, cfg.RECIPIENT_Y, 0.001); + assertEquals(-27.482, cfg.RECIPIENT_Z, 0.001); + assertEquals(-90.9f, cfg.RECIPIENT_YAW, 0.01f); + assertEquals(3.2f, cfg.RECIPIENT_PITCH, 0.01f); + assertEquals("minecraft:overworld", cfg.LEVEL); + } +} diff --git a/src/test/java/io/icker/factions/core/AnnouncerManagerTest.java b/src/test/java/io/icker/factions/core/AnnouncerManagerTest.java new file mode 100644 index 00000000..a48b3bac --- /dev/null +++ b/src/test/java/io/icker/factions/core/AnnouncerManagerTest.java @@ -0,0 +1,79 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TextColor; + +import org.junit.jupiter.api.Test; + +class AnnouncerManagerTest { + private static final int FADE_STEPS = 6; + + @Test + void wildernessAnnouncementIsNotNull() { + Component component = AnnouncerManager.buildAnnouncement(null); + + assertNotNull(component); + } + + @Test + void wildernessAnnouncementContainsWildernessText() { + Component component = AnnouncerManager.buildAnnouncement(null); + + assertTrue(component.getString().contains("Wilderness")); + } + + @Test + void wildernessAnnouncementHasNoDescriptionSeparator() { + Component component = AnnouncerManager.buildAnnouncement(null); + + assertTrue(!component.getString().contains("•")); + } + + @Test + void wildernessAnnouncementIsBold() { + Component component = AnnouncerManager.buildAnnouncement(null); + + assertTrue( + component.getStyle().isBold(), + "wilderness must be bold so it matches faction announcements"); + } + + @Test + void wildernessFadeStepFinalMatchesTargetWhite() { + Component finalStep = AnnouncerManager.buildFadeStep(null, FADE_STEPS - 1, FADE_STEPS); + + TextColor color = finalStep.getStyle().getColor(); + assertNotNull(color); + TextColor expected = TextColor.fromLegacyFormat(ChatFormatting.WHITE); + assertNotNull(expected); + assertEquals(expected.getValue(), color.getValue()); + } + + @Test + void wildernessFadeStepFirstIsDarkerThanFinal() { + Component firstStep = AnnouncerManager.buildFadeStep(null, 0, FADE_STEPS); + Component finalStep = AnnouncerManager.buildFadeStep(null, FADE_STEPS - 1, FADE_STEPS); + + TextColor firstColor = firstStep.getStyle().getColor(); + TextColor finalColor = finalStep.getStyle().getColor(); + assertNotNull(firstColor); + assertNotNull(finalColor); + assertNotEquals(firstColor.getValue(), finalColor.getValue()); + assertTrue( + brightness(firstColor.getValue()) < brightness(finalColor.getValue()), + "first fade step must be dimmer than final fade step"); + } + + private static int brightness(int rgb) { + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + return r + g + b; + } +} diff --git a/src/test/java/io/icker/factions/core/RestrictedItemsTest.java b/src/test/java/io/icker/factions/core/RestrictedItemsTest.java new file mode 100644 index 00000000..6f8136d9 --- /dev/null +++ b/src/test/java/io/icker/factions/core/RestrictedItemsTest.java @@ -0,0 +1,85 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +class RestrictedItemsTest { + /** + * Mirrors the pure ID-in-list matching logic of {@link InteractionManager#isRestrictedForGuests} + * without touching Minecraft registries or {@code FactionsMod.CONFIG}, which throw under + * FabricLoader in the unit-test environment. + */ + private static boolean matches(String id, List restricted) { + if (id == null || id.isEmpty()) return false; + if (restricted == null || restricted.isEmpty()) return false; + return restricted.contains(id); + } + + private static List defaultList() { + return new ArrayList<>( + List.of( + "minecraft:flint_and_steel", + "minecraft:fire_charge", + "minecraft:lava_bucket")); + } + + @Test + void idInRestrictedListMatches() { + List restricted = defaultList(); + + assertTrue(matches("minecraft:flint_and_steel", restricted)); + assertTrue(matches("minecraft:fire_charge", restricted)); + assertTrue(matches("minecraft:lava_bucket", restricted)); + } + + @Test + void idNotInRestrictedListDoesNotMatch() { + List restricted = defaultList(); + + assertFalse(matches("minecraft:stone", restricted)); + assertFalse(matches("minecraft:water_bucket", restricted)); + assertFalse(matches("minecraft:diamond_sword", restricted)); + } + + @Test + void emptyRestrictedListMatchesNothing() { + List restricted = new ArrayList<>(); + + assertFalse(matches("minecraft:flint_and_steel", restricted)); + assertFalse(matches("minecraft:lava_bucket", restricted)); + } + + @Test + void nullRestrictedListMatchesNothing() { + assertFalse(matches("minecraft:flint_and_steel", null)); + assertFalse(matches("minecraft:lava_bucket", null)); + } + + @Test + void matchingIsExactAndDoesNotPrefixMatch() { + List restricted = defaultList(); + + assertFalse( + matches("minecraft:lava_bucket_thing", restricted), + "lava_bucket must not match a longer id that starts with it"); + assertFalse( + matches("minecraft:flint_and_steele", restricted), + "flint_and_steel must not match a trailing typo"); + assertFalse( + matches("modname:lava_bucket", restricted), + "namespaced ids from another mod must not match vanilla ids"); + } + + @Test + void nullOrEmptyIdDoesNotMatch() { + List restricted = defaultList(); + + assertFalse(matches(null, restricted)); + assertFalse(matches("", restricted)); + } +} diff --git a/src/test/java/io/icker/factions/core/TeleportRequestManagerTest.java b/src/test/java/io/icker/factions/core/TeleportRequestManagerTest.java new file mode 100644 index 00000000..abff8686 --- /dev/null +++ b/src/test/java/io/icker/factions/core/TeleportRequestManagerTest.java @@ -0,0 +1,219 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.icker.factions.core.TeleportRequestManager.Request; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +class TeleportRequestManagerTest { + private static final int EXPIRY_SECONDS = 60; + private static final int COOLDOWN_SECONDS = 15; + private static final long T0 = 1_000_000L; + + private UUID requester; + private UUID target; + private UUID other; + + @BeforeEach + void resetState() { + requester = UUID.randomUUID(); + target = UUID.randomUUID(); + other = UUID.randomUUID(); + + // Clean any lingering static state on the UUIDs this test will use. + TeleportRequestManager.clearPlayer(requester); + TeleportRequestManager.clearPlayer(target); + TeleportRequestManager.clearPlayer(other); + } + + @Test + void request_then_peek_returnsStoredRequesterID() { + TeleportRequestManager.request(target, requester, T0); + + Request req = TeleportRequestManager.peek(target); + + assertNotNull(req, "peek should return the stored request"); + assertEquals(requester, req.requesterID(), "peek must return the correct requester UUID"); + assertEquals(T0, req.createdAtMillis(), "peek must return the createdAt millis it was stored with"); + } + + @Test + void isExpired_falseJustInsideWindow() { + TeleportRequestManager.request(target, requester, T0); + Request req = TeleportRequestManager.peek(target); + + // one millisecond before the expiry boundary + long now = T0 + (EXPIRY_SECONDS * 1000L) - 1; + + assertFalse( + TeleportRequestManager.isExpired(req, now, EXPIRY_SECONDS), + "request should NOT be expired one millisecond before the boundary"); + } + + @Test + void isExpired_trueJustOutsideWindow() { + TeleportRequestManager.request(target, requester, T0); + Request req = TeleportRequestManager.peek(target); + + // one millisecond past the expiry boundary + long now = T0 + (EXPIRY_SECONDS * 1000L) + 1; + + assertTrue( + TeleportRequestManager.isExpired(req, now, EXPIRY_SECONDS), + "request MUST be expired one millisecond past the boundary"); + } + + @Test + void consume_returnsRequestAndClearsIt() { + TeleportRequestManager.request(target, requester, T0); + + Request consumed = TeleportRequestManager.consume(target); + + assertNotNull(consumed, "consume should return the stored request"); + assertEquals(requester, consumed.requesterID()); + assertNull(TeleportRequestManager.peek(target), "second peek after consume must be null"); + } + + @Test + void deny_clearsPendingRequest() { + TeleportRequestManager.request(target, requester, T0); + + TeleportRequestManager.deny(target); + + assertNull(TeleportRequestManager.peek(target), "deny must clear the pending request"); + } + + @Test + void isOnCooldown_trueImmediatelyAfterMarkTeleported() { + TeleportRequestManager.markTeleported(requester, T0); + + assertTrue( + TeleportRequestManager.isOnCooldown(requester, T0, COOLDOWN_SECONDS), + "player must be on cooldown immediately after marking teleport"); + } + + @Test + void isOnCooldown_falseAfterCooldownWindow() { + TeleportRequestManager.markTeleported(requester, T0); + + long past = T0 + (COOLDOWN_SECONDS * 1000L); + + assertFalse( + TeleportRequestManager.isOnCooldown(requester, past, COOLDOWN_SECONDS), + "cooldown must lapse exactly at the boundary (nowMillis - last < window)"); + } + + @Test + void isOnCooldown_falseWhenNoPriorTeleport() { + assertFalse( + TeleportRequestManager.isOnCooldown(requester, T0, COOLDOWN_SECONDS), + "player with no teleport history must not be on cooldown"); + } + + @Test + void cooldownRemainingMillis_decreasesAsTimeAdvances() { + TeleportRequestManager.markTeleported(requester, T0); + + long early = TeleportRequestManager.cooldownRemainingMillis(requester, T0, COOLDOWN_SECONDS); + long later = + TeleportRequestManager.cooldownRemainingMillis( + requester, T0 + 5_000L, COOLDOWN_SECONDS); + + assertEquals( + COOLDOWN_SECONDS * 1000L, + early, + "at t=0 the remaining cooldown should be the full window"); + assertTrue(later < early, "remaining cooldown must decrease as time advances"); + assertEquals( + (COOLDOWN_SECONDS * 1000L) - 5_000L, + later, + "remaining cooldown after 5s should shrink by exactly 5000ms"); + } + + @Test + void cooldownRemainingMillis_clampsToZeroPastWindow() { + TeleportRequestManager.markTeleported(requester, T0); + + long past = T0 + (COOLDOWN_SECONDS * 1000L) + 5_000L; + + assertEquals( + 0L, + TeleportRequestManager.cooldownRemainingMillis(requester, past, COOLDOWN_SECONDS), + "cooldown remaining must clamp to 0 past the window"); + } + + @Test + void cooldownRemainingMillis_zeroWhenNoHistory() { + assertEquals( + 0L, + TeleportRequestManager.cooldownRemainingMillis(requester, T0, COOLDOWN_SECONDS), + "cooldown remaining for a player with no history must be 0"); + } + + @Test + void clearPlayer_removesEntryWherePlayerIsTarget() { + TeleportRequestManager.request(target, requester, T0); + + TeleportRequestManager.clearPlayer(target); + + assertNull( + TeleportRequestManager.peek(target), + "clearPlayer(target) must remove the pending request keyed by target"); + } + + @Test + void clearPlayer_removesEntriesWherePlayerIsRequester() { + // The requester has a request outstanding against `target` AND against `other`. + TeleportRequestManager.request(target, requester, T0); + TeleportRequestManager.request(other, requester, T0); + + TeleportRequestManager.clearPlayer(requester); + + assertNull( + TeleportRequestManager.peek(target), + "clearPlayer(requester) must remove requests where they are the requester (target key)"); + assertNull( + TeleportRequestManager.peek(other), + "clearPlayer(requester) must remove requests where they are the requester (other key)"); + } + + @Test + void clearPlayer_removesCooldownEntry() { + TeleportRequestManager.markTeleported(requester, T0); + assertTrue(TeleportRequestManager.isOnCooldown(requester, T0, COOLDOWN_SECONDS)); + + TeleportRequestManager.clearPlayer(requester); + + assertFalse( + TeleportRequestManager.isOnCooldown(requester, T0, COOLDOWN_SECONDS), + "clearPlayer must drop the player's cooldown entry"); + } + + @Test + void newerRequest_replacesOlderForSameTarget() { + UUID firstRequester = UUID.randomUUID(); + UUID secondRequester = UUID.randomUUID(); + // Ensure clean state for the ad-hoc UUIDs used in this test. + TeleportRequestManager.clearPlayer(firstRequester); + TeleportRequestManager.clearPlayer(secondRequester); + + TeleportRequestManager.request(target, firstRequester, T0); + TeleportRequestManager.request(target, secondRequester, T0 + 5_000L); + + Request req = TeleportRequestManager.peek(target); + assertNotNull(req); + assertEquals( + secondRequester, + req.requesterID(), + "the newer request must replace the older one for the same target"); + assertEquals(T0 + 5_000L, req.createdAtMillis(), "createdAt must reflect the newer request"); + } +} diff --git a/src/test/java/io/icker/factions/core/TradeAtomicSwapTest.java b/src/test/java/io/icker/factions/core/TradeAtomicSwapTest.java new file mode 100644 index 00000000..e47956e9 --- /dev/null +++ b/src/test/java/io/icker/factions/core/TradeAtomicSwapTest.java @@ -0,0 +1,238 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.icker.factions.core.TradeSession.Cause; +import io.icker.factions.core.TradeSession.GuiOpenAction; +import io.icker.factions.core.TradeSession.InventoryAdapter; +import io.icker.factions.core.TradeSession.Phase; +import io.icker.factions.core.TradeSession.PreviousPosition; +import io.icker.factions.core.TradeSession.TeleportAction; + +import net.minecraft.world.SimpleContainer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Unit tests for the atomic-swap invariant of {@link TradeSession}. + * + *

The swap runs inside a single server-tick runnable and is gated by a pre-flight + * capacity check. Both halves must succeed or neither happens — items in a placement + * container are never moved when the mirroring receiver cannot hold them, and the + * containers are handed to {@link InventoryAdapter#returnOrDrop} on every cancel path + * so that no stack disappears. + * + *

The tests below exercise: + *

    + *
  • The pure capacity math ({@link TradeSession#canFitAll(SimpleContainer, SimpleContainer)}) + * and vanilla-parity distribution ({@link TradeSession#addToContainer(SimpleContainer, + * net.minecraft.world.item.ItemStack)}) — trivial cases that do not require a real Item + * registry (no {@code Bootstrap.bootStrap()}).
  • + *
  • The observable rollback: when commit runs in an environment without a live + * server ({@code WorldUtils.server == null}), the swap bails and the cancel handler + * routes both placement containers through {@link InventoryAdapter}.
  • + *
  • The commit-phase guards: a session already in CANCELLED / COMPLETED cannot be + * re-committed.
  • + *
+ */ +class TradeAtomicSwapTest { + private UUID requesterId; + private UUID recipientId; + + private TeleportAction savedTeleport; + private GuiOpenAction savedGuiOpen; + private InventoryAdapter savedInventory; + + @BeforeEach + void resetAndOverrideSeams() { + requesterId = UUID.randomUUID(); + recipientId = UUID.randomUUID(); + + savedTeleport = TradeSession.teleportImpl; + savedGuiOpen = TradeSession.guiOpenImpl; + savedInventory = TradeSession.inventoryImpl; + + TradeSession.teleportImpl = (id, lvl, x, y, z, yaw, pitch) -> {}; + TradeSession.guiOpenImpl = (id, phase) -> {}; + TradeSession.inventoryImpl = (container, playerId, prev, forceDropAll) -> {}; + } + + @AfterEach + void cleanupSessions() { + TradeSession.getFor(requesterId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + TradeSession.getFor(recipientId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + + TradeSession.teleportImpl = savedTeleport; + TradeSession.guiOpenImpl = savedGuiOpen; + TradeSession.inventoryImpl = savedInventory; + } + + @Test + void canFitAll_bothEmpty_returnsTrue() { + SimpleContainer snapshot = new SimpleContainer(36); + SimpleContainer incoming = new SimpleContainer(27); + + assertTrue(TradeSession.canFitAll(snapshot, incoming), + "an empty incoming payload must always fit — nothing to deliver"); + } + + @Test + void canFitAll_snapshotAtFullCapacity_emptyIncoming_stillReturnsTrue() { + SimpleContainer snapshot = new SimpleContainer(36); + SimpleContainer incoming = new SimpleContainer(27); + + assertTrue(TradeSession.canFitAll(snapshot, incoming), + "a full snapshot must still accept an empty payload — the capacity check " + + "only iterates the incoming side"); + } + + @Test + void commitBeforeReveal_isNoop() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + assertEquals(Phase.PLACEMENT, session.getPhase()); + + TradeSession.commit(session.getId()); + + assertEquals(Phase.PLACEMENT, session.getPhase(), + "commit MUST NOT advance phase from PLACEMENT — REVEAL is the only " + + "legal entry point"); + } + + @Test + void commitAfterCancelled_isNoop() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + assertEquals(Phase.CANCELLED, session.getPhase()); + + TradeSession.commit(session.getId()); + + assertEquals(Phase.CANCELLED, session.getPhase(), + "commit MUST NOT reanimate a CANCELLED session"); + } + + @Test + void commitAfterCompleted_isNoop() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + session.phase = Phase.COMPLETED; + + TradeSession.commit(session.getId()); + + assertEquals(Phase.COMPLETED, session.getPhase(), + "commit MUST NOT retrigger a COMPLETED session"); + } + + @Test + void bothAcceptWithoutServer_bailsToCancelledAndReturnsBothContainers() { + record Capture(SimpleContainer container, UUID playerId, PreviousPosition prev, boolean forceDropAll) {} + List captured = new ArrayList<>(); + TradeSession.inventoryImpl = + (container, playerId, prev, forceDropAll) -> + captured.add(new Capture(container, playerId, prev, forceDropAll)); + + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + TradeSession.confirmPlacement(session.getId(), requesterId); + TradeSession.confirmPlacement(session.getId(), recipientId); + assertEquals(Phase.REVEAL, session.getPhase()); + + TradeSession.acceptReveal(session.getId(), requesterId); + TradeSession.acceptReveal(session.getId(), recipientId); + + assertEquals(Phase.CANCELLED, session.getPhase(), + "without WorldUtils.server, performSwapWork bails via cancelFromSwap " + + "and the phase settles at CANCELLED"); + assertEquals(2, captured.size(), + "both placement containers must be handed to the InventoryAdapter — " + + "one per player — so no items are orphaned by the failed swap"); + + List capturedPlayers = captured.stream().map(Capture::playerId).toList(); + assertTrue(capturedPlayers.contains(requesterId), + "the requester's container must be routed through the adapter"); + assertTrue(capturedPlayers.contains(recipientId), + "the recipient's container must be routed through the adapter"); + + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "SERVER_STOPPING with non-null player references never sets forceDropAll — " + + "that flag is reserved for DISCONNECTED with an offline player"); + } + + assertNull(TradeSession.getFor(requesterId).orElse(null), + "the session must be removed from the requester index after cancel"); + assertNull(TradeSession.getFor(recipientId).orElse(null), + "the session must be removed from the recipient index after cancel"); + } + + @Test + void failedSwap_routesTheCorrectContainerToEachPlayer() { + record Capture(SimpleContainer container, UUID playerId) {} + List captured = new ArrayList<>(); + TradeSession.inventoryImpl = + (container, playerId, prev, forceDropAll) -> + captured.add(new Capture(container, playerId)); + + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + SimpleContainer requesterPlacement = session.getRequesterPlacement(); + SimpleContainer recipientPlacement = session.getRecipientPlacement(); + + TradeSession.confirmPlacement(session.getId(), requesterId); + TradeSession.confirmPlacement(session.getId(), recipientId); + TradeSession.acceptReveal(session.getId(), requesterId); + TradeSession.acceptReveal(session.getId(), recipientId); + + assertEquals(2, captured.size()); + + Capture reqCapture = + captured.stream() + .filter(c -> c.playerId().equals(requesterId)) + .findFirst() + .orElse(null); + Capture recCapture = + captured.stream() + .filter(c -> c.playerId().equals(recipientId)) + .findFirst() + .orElse(null); + + assertNotNull(reqCapture, "requester capture must exist"); + assertNotNull(recCapture, "recipient capture must exist"); + assertSame(requesterPlacement, reqCapture.container(), + "the requester MUST receive its OWN placement back — never the recipient's, " + + "which would be a mis-route dupe"); + assertSame(recipientPlacement, recCapture.container(), + "the recipient MUST receive its OWN placement back — never the requester's"); + } + + @Test + void failedSwap_placementContainersAreUnmodifiedByTheGuard() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + SimpleContainer requesterPlacement = session.getRequesterPlacement(); + SimpleContainer recipientPlacement = session.getRecipientPlacement(); + + int reqSize = requesterPlacement.getContainerSize(); + int recSize = recipientPlacement.getContainerSize(); + + TradeSession.confirmPlacement(session.getId(), requesterId); + TradeSession.confirmPlacement(session.getId(), recipientId); + TradeSession.acceptReveal(session.getId(), requesterId); + TradeSession.acceptReveal(session.getId(), recipientId); + + assertEquals(reqSize, requesterPlacement.getContainerSize(), + "the requester placement container size must not change across the failed swap"); + assertEquals(recSize, recipientPlacement.getContainerSize(), + "the recipient placement container size must not change across the failed swap"); + assertEquals(27, reqSize, + "the trade placement container is always the fixed 27-slot single chest"); + } +} diff --git a/src/test/java/io/icker/factions/core/TradeCancelReturnsItemsTest.java b/src/test/java/io/icker/factions/core/TradeCancelReturnsItemsTest.java new file mode 100644 index 00000000..3656af0c --- /dev/null +++ b/src/test/java/io/icker/factions/core/TradeCancelReturnsItemsTest.java @@ -0,0 +1,231 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.icker.factions.core.TradeSession.Cause; +import io.icker.factions.core.TradeSession.GuiOpenAction; +import io.icker.factions.core.TradeSession.InventoryAdapter; +import io.icker.factions.core.TradeSession.Phase; +import io.icker.factions.core.TradeSession.PreviousPosition; +import io.icker.factions.core.TradeSession.TeleportAction; + +import net.minecraft.world.SimpleContainer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Unit tests for the cancel-path item-return contract of {@link TradeSession}. + * + *

The rule this suite pins is: for every non-server cancel Cause, the + * {@link InventoryAdapter} is invoked once per player with the correct placement + * container and the correct {@code forceDropAll} flag. {@code forceDropAll} is + * ONLY set for {@link Cause#DISCONNECTED} + an offline player — every other Cause + * MUST route through the "return to inventory, drop overflow" path so that a live + * player never loses items to the ground. + * + *

{@link TradeSession#cancel} short-circuits when {@link Phase#SWAPPING}, + * {@link Phase#CANCELLED}, or {@link Phase#COMPLETED} is already set, so all tests + * start from PLACEMENT and rely on the tests in {@code TradeSessionStateMachineTest} + * to prove those guards. + */ +class TradeCancelReturnsItemsTest { + private UUID requesterId; + private UUID recipientId; + + private TeleportAction savedTeleport; + private GuiOpenAction savedGuiOpen; + private InventoryAdapter savedInventory; + + private record Capture( + SimpleContainer container, + UUID playerId, + PreviousPosition prev, + boolean forceDropAll) {} + + private final List captured = new ArrayList<>(); + + @BeforeEach + void resetAndOverrideSeams() { + requesterId = UUID.randomUUID(); + recipientId = UUID.randomUUID(); + captured.clear(); + + savedTeleport = TradeSession.teleportImpl; + savedGuiOpen = TradeSession.guiOpenImpl; + savedInventory = TradeSession.inventoryImpl; + + TradeSession.teleportImpl = (id, lvl, x, y, z, yaw, pitch) -> {}; + TradeSession.guiOpenImpl = (id, phase) -> {}; + TradeSession.inventoryImpl = + (container, playerId, prev, forceDropAll) -> + captured.add(new Capture(container, playerId, prev, forceDropAll)); + } + + @AfterEach + void cleanupSessions() { + TradeSession.getFor(requesterId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + TradeSession.getFor(recipientId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + + TradeSession.teleportImpl = savedTeleport; + TradeSession.guiOpenImpl = savedGuiOpen; + TradeSession.inventoryImpl = savedInventory; + } + + @Test + void cancelPlayerCancelled_invokesAdapterOncePerPlayerWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + + assertEquals(2, captured.size(), + "PLAYER_CANCELLED must invoke the adapter once per player"); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "PLAYER_CANCELLED must NEVER set forceDropAll — live players return items"); + } + } + + @Test + void cancelDeclined_invokesAdapterWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.DECLINED); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "DECLINED must return items, not drop them — the players are online"); + } + } + + @Test + void cancelInventoryFull_invokesAdapterWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.INVENTORY_FULL); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "INVENTORY_FULL must return items — a full inventory is not a disconnect"); + } + } + + @Test + void cancelGuiClosed_invokesAdapterWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.GUI_CLOSED); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "GUI_CLOSED must return items — closing the screen does not mean the " + + "player left"); + } + } + + @Test + void cancelFactionDisbanded_invokesAdapterWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.FACTION_DISBANDED); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "FACTION_DISBANDED must return items — the players did not leave the server"); + } + } + + @Test + void cancelServerStopping_invokesAdapterWithForceDropAllFalse() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.SERVER_STOPPING); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertFalse(c.forceDropAll(), + "SERVER_STOPPING must not force-drop — the flag is scoped to DISCONNECTED " + + "with a null player reference"); + } + } + + @Test + void cancelDisconnected_offlinePlayers_setsForceDropAllTrue() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.DISCONNECTED); + + assertEquals(2, captured.size()); + for (Capture c : captured) { + assertTrue(c.forceDropAll(), + "DISCONNECTED with no live server (players offline) must set forceDropAll — " + + "there is no inventory to return items to"); + } + } + + @Test + void cancel_routesTheCorrectContainerToEachPlayerId() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + SimpleContainer requesterPlacement = session.getRequesterPlacement(); + SimpleContainer recipientPlacement = session.getRecipientPlacement(); + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + + assertEquals(2, captured.size()); + Capture forRequester = + captured.stream() + .filter(c -> c.playerId().equals(requesterId)) + .findFirst() + .orElseThrow(); + Capture forRecipient = + captured.stream() + .filter(c -> c.playerId().equals(recipientId)) + .findFirst() + .orElseThrow(); + + assertSame(requesterPlacement, forRequester.container(), + "the requester MUST receive its OWN placement container back — " + + "cross-wired containers would be a dupe/loss bug"); + assertSame(recipientPlacement, forRecipient.container(), + "the recipient MUST receive its OWN placement container back"); + } + + @Test + void cancel_passesTheStoredPreviousPositionToTheAdapter() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + PreviousPosition expectedRequesterPrev = session.prevPositionFor(requesterId); + PreviousPosition expectedRecipientPrev = session.prevPositionFor(recipientId); + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + + Capture forRequester = + captured.stream() + .filter(c -> c.playerId().equals(requesterId)) + .findFirst() + .orElseThrow(); + Capture forRecipient = + captured.stream() + .filter(c -> c.playerId().equals(recipientId)) + .findFirst() + .orElseThrow(); + + assertSame(expectedRequesterPrev, forRequester.prev(), + "the adapter must receive the pre-session position for overflow drop"); + assertSame(expectedRecipientPrev, forRecipient.prev(), + "the adapter must receive the pre-session position for overflow drop"); + } +} diff --git a/src/test/java/io/icker/factions/core/TradeRequestManagerTest.java b/src/test/java/io/icker/factions/core/TradeRequestManagerTest.java new file mode 100644 index 00000000..bbcb16ee --- /dev/null +++ b/src/test/java/io/icker/factions/core/TradeRequestManagerTest.java @@ -0,0 +1,197 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.icker.factions.core.TradeRequestManager.TradeRequest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +/** + * Pure-state unit tests for {@link TradeRequestManager}. + * + *

{@code TradeRequestManager} holds its pending-request table in a static + * {@link java.util.concurrent.ConcurrentHashMap}, so every test allocates fresh + * {@link UUID}s in {@link #resetState()} and defensively calls + * {@link TradeRequestManager#clearFactionRequests(UUID)} on each of them to + * guarantee we never see state leak from an earlier test method. + */ +class TradeRequestManagerTest { + private static final int EXPIRY_SECONDS = 60; + private static final long T0 = 1_000_000L; + + private UUID targetFaction; + private UUID requesterFaction; + private UUID requesterPlayer; + private UUID otherFaction; + + @BeforeEach + void resetState() { + targetFaction = UUID.randomUUID(); + requesterFaction = UUID.randomUUID(); + requesterPlayer = UUID.randomUUID(); + otherFaction = UUID.randomUUID(); + + // Defensive: scrub any lingering static state on the UUIDs this test will use. + TradeRequestManager.clearFactionRequests(targetFaction); + TradeRequestManager.clearFactionRequests(requesterFaction); + TradeRequestManager.clearFactionRequests(otherFaction); + } + + @Test + void requestAndPeek() { + // Given a trade request stored against `targetFaction` + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + + // When we peek the target's slot + TradeRequest req = TradeRequestManager.peek(targetFaction); + + // Then we get back the exact triple we stored + assertNotNull(req, "peek should return the stored request"); + assertEquals(requesterFaction, req.requesterFactionID(), "requester faction UUID must round-trip"); + assertEquals(requesterPlayer, req.requesterPlayerID(), "requester player UUID must round-trip"); + assertEquals(T0, req.createdAtMillis(), "createdAt millis must round-trip"); + } + + @Test + void peekReturnsNullWhenNone() { + // Given no request has been made for `targetFaction` + + // When we peek + TradeRequest req = TradeRequestManager.peek(targetFaction); + + // Then the slot is empty + assertNull(req, "peek with no pending request must return null"); + } + + @Test + void consumeRemovesRequest() { + // Given a pending request + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + + // When we consume it + TradeRequest consumed = TradeRequestManager.consume(targetFaction); + + // Then consume returns the request and the slot is empty on the next peek + assertNotNull(consumed, "consume should return the stored request"); + assertEquals(requesterFaction, consumed.requesterFactionID()); + assertEquals(requesterPlayer, consumed.requesterPlayerID()); + assertNull(TradeRequestManager.peek(targetFaction), "second peek after consume must be null"); + } + + @Test + void denyRemovesRequest() { + // Given a pending request + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + + // When we deny it + TradeRequestManager.deny(targetFaction); + + // Then the slot is empty + assertNull(TradeRequestManager.peek(targetFaction), "deny must clear the pending request"); + } + + @Test + void isExpiredFalseBeforeExpiry() { + // Given a request created at T0 with a 60s expiry window + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + TradeRequest req = TradeRequestManager.peek(targetFaction); + + // When only 30s have elapsed (well inside the 60s window) + long now = T0 + 30_000L; + + // Then the request is NOT expired + assertFalse( + TradeRequestManager.isExpired(req, now, EXPIRY_SECONDS), + "30s elapsed against a 60s expiry must not be expired"); + } + + @Test + void isExpiredTrueAfterExpiry() { + // Given a request created at T0 with a 60s expiry window + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + TradeRequest req = TradeRequestManager.peek(targetFaction); + + // When 61s have elapsed (past the 60s window) + long now = T0 + 61_000L; + + // Then the request IS expired + assertTrue( + TradeRequestManager.isExpired(req, now, EXPIRY_SECONDS), + "61s elapsed against a 60s expiry must be expired"); + } + + @Test + void requestOverwritesPrevious() { + // Given two requests fired against the same target from two different requesters + UUID firstRequesterFaction = UUID.randomUUID(); + UUID firstRequesterPlayer = UUID.randomUUID(); + UUID secondRequesterFaction = UUID.randomUUID(); + UUID secondRequesterPlayer = UUID.randomUUID(); + // Scrub any state on the ad-hoc UUIDs. + TradeRequestManager.clearFactionRequests(firstRequesterFaction); + TradeRequestManager.clearFactionRequests(secondRequesterFaction); + + TradeRequestManager.request(targetFaction, firstRequesterFaction, firstRequesterPlayer, T0); + TradeRequestManager.request( + targetFaction, secondRequesterFaction, secondRequesterPlayer, T0 + 5_000L); + + // When we peek + TradeRequest req = TradeRequestManager.peek(targetFaction); + + // Then only the newer request is kept (single slot per target) + assertNotNull(req); + assertEquals( + secondRequesterFaction, + req.requesterFactionID(), + "the newer request must overwrite the older for the same target"); + assertEquals( + secondRequesterPlayer, + req.requesterPlayerID(), + "the newer request's player must overwrite the older one"); + assertEquals( + T0 + 5_000L, + req.createdAtMillis(), + "createdAt must reflect the newer request"); + } + + @Test + void clearFactionRequestsRemovesTargetSide() { + // Given a request pending against `targetFaction` + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + + // When we clear the target faction + TradeRequestManager.clearFactionRequests(targetFaction); + + // Then the request keyed by the target is gone + assertNull( + TradeRequestManager.peek(targetFaction), + "clearFactionRequests(target) must remove the pending request keyed by that target"); + } + + @Test + void clearFactionRequestsRemovesRequesterSide() { + // Given the requester has outstanding requests against TWO different targets + // (this is the leak vector: the map is keyed by target, so simply removing the + // requester UUID as a key would miss both entries). + TradeRequestManager.request(targetFaction, requesterFaction, requesterPlayer, T0); + TradeRequestManager.request(otherFaction, requesterFaction, requesterPlayer, T0); + + // When we clear the requester faction + TradeRequestManager.clearFactionRequests(requesterFaction); + + // Then both target-keyed slots that referenced the requester are cleared + assertNull( + TradeRequestManager.peek(targetFaction), + "clearFactionRequests(requester) must remove requests where requester is the sender (target-keyed)"); + assertNull( + TradeRequestManager.peek(otherFaction), + "clearFactionRequests(requester) must remove requests where requester is the sender (otherFaction-keyed)"); + } +} diff --git a/src/test/java/io/icker/factions/core/TradeSessionStateMachineTest.java b/src/test/java/io/icker/factions/core/TradeSessionStateMachineTest.java new file mode 100644 index 00000000..31875e8c --- /dev/null +++ b/src/test/java/io/icker/factions/core/TradeSessionStateMachineTest.java @@ -0,0 +1,181 @@ +package io.icker.factions.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.icker.factions.core.TradeSession.Cause; +import io.icker.factions.core.TradeSession.GuiOpenAction; +import io.icker.factions.core.TradeSession.InventoryAdapter; +import io.icker.factions.core.TradeSession.Phase; +import io.icker.factions.core.TradeSession.TeleportAction; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +/** + * Unit tests for {@link TradeSession}'s phase transitions. + * + *

{@code TradeSession} maintains a static {@link java.util.concurrent.ConcurrentHashMap} of + * live sessions plus a per-player index. Every test allocates fresh {@link UUID}s in + * {@link #resetAndOverrideSeams()} and cancels any lingering session in {@link #cleanupSessions()} + * so that state cannot leak between tests. The seams {@link TeleportAction}, + * {@link GuiOpenAction} and {@link InventoryAdapter} are also captured and restored to guarantee + * one test's stub cannot bleed into another. + */ +class TradeSessionStateMachineTest { + private UUID requesterId; + private UUID recipientId; + + private TeleportAction savedTeleport; + private GuiOpenAction savedGuiOpen; + private InventoryAdapter savedInventory; + + @BeforeEach + void resetAndOverrideSeams() { + requesterId = UUID.randomUUID(); + recipientId = UUID.randomUUID(); + + savedTeleport = TradeSession.teleportImpl; + savedGuiOpen = TradeSession.guiOpenImpl; + savedInventory = TradeSession.inventoryImpl; + + TradeSession.teleportImpl = (id, lvl, x, y, z, yaw, pitch) -> {}; + TradeSession.guiOpenImpl = (id, phase) -> {}; + TradeSession.inventoryImpl = (container, playerId, prev, forceDropAll) -> {}; + } + + @AfterEach + void cleanupSessions() { + TradeSession.getFor(requesterId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + TradeSession.getFor(recipientId) + .ifPresent(s -> TradeSession.cancel(s.getId(), Cause.SERVER_STOPPING)); + + TradeSession.teleportImpl = savedTeleport; + TradeSession.guiOpenImpl = savedGuiOpen; + TradeSession.inventoryImpl = savedInventory; + } + + @Test + void beginSession_phaseIsPlacement() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + assertNotNull(session, "beginForTest must register a session"); + assertEquals(Phase.PLACEMENT, session.getPhase(), + "a fresh session must start in PLACEMENT"); + assertEquals(session, + TradeSession.getFor(requesterId).orElse(null), + "the requester must be indexed to the new session"); + assertEquals(session, + TradeSession.getFor(recipientId).orElse(null), + "the recipient must be indexed to the new session"); + } + + @Test + void confirmPlacement_bothConfirm_phaseBecomesReveal() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.confirmPlacement(session.getId(), requesterId); + assertEquals(Phase.PLACEMENT, session.getPhase(), + "one confirm must not advance out of PLACEMENT"); + + TradeSession.confirmPlacement(session.getId(), recipientId); + assertEquals(Phase.REVEAL, session.getPhase(), + "both confirms must advance to REVEAL"); + } + + @Test + void confirmPlacement_oneConfirm_phaseStaysPlacement() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.confirmPlacement(session.getId(), requesterId); + + assertEquals(Phase.PLACEMENT, session.getPhase(), + "only requester confirmed — must still be PLACEMENT"); + assertTrue(session.requesterConfirmed, + "requester confirm flag must be flipped"); + assertFalse(session.recipientConfirmed, + "recipient confirm flag must remain unset"); + } + + @Test + void renegotiate_fromReveal_phaseBecomesPlacement() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + TradeSession.confirmPlacement(session.getId(), requesterId); + TradeSession.confirmPlacement(session.getId(), recipientId); + assertEquals(Phase.REVEAL, session.getPhase()); + + TradeSession.renegotiate(session.getId()); + + assertEquals(Phase.PLACEMENT, session.getPhase(), + "renegotiate must return to PLACEMENT"); + assertFalse(session.requesterConfirmed, + "renegotiate must clear the requester confirm flag"); + assertFalse(session.recipientConfirmed, + "renegotiate must clear the recipient confirm flag"); + assertFalse(session.requesterAccepted, + "renegotiate must clear the requester accept flag"); + assertFalse(session.recipientAccepted, + "renegotiate must clear the recipient accept flag"); + } + + @Test + void acceptReveal_bothAccept_transitionsOutOfReveal() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + TradeSession.confirmPlacement(session.getId(), requesterId); + TradeSession.confirmPlacement(session.getId(), recipientId); + assertEquals(Phase.REVEAL, session.getPhase()); + + TradeSession.acceptReveal(session.getId(), requesterId); + assertEquals(Phase.REVEAL, session.getPhase(), + "one accept must not advance out of REVEAL"); + assertTrue(session.requesterAccepted); + assertFalse(session.recipientAccepted); + + TradeSession.acceptReveal(session.getId(), recipientId); + assertTrue(session.recipientAccepted, "both accept flags must be set"); + + assertNotEquals(Phase.REVEAL, session.getPhase(), + "both accepts must advance out of REVEAL — commit was triggered"); + } + + @Test + void cancelFromPlacement_phaseBecomesCancelled() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + + assertEquals(Phase.CANCELLED, session.getPhase(), + "cancel from PLACEMENT must transition to CANCELLED"); + } + + @Test + void doubleCancelIsNoop() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + assertEquals(Phase.CANCELLED, session.getPhase()); + + TradeSession.cancel(session.getId(), Cause.DECLINED); + + assertEquals(Phase.CANCELLED, session.getPhase(), + "second cancel must NOT change the CANCELLED phase"); + } + + @Test + void cancelAfterCompletedIsNoop() { + TradeSession session = TradeSession.beginForTest(requesterId, recipientId); + session.phase = Phase.COMPLETED; + + TradeSession.cancel(session.getId(), Cause.PLAYER_CANCELLED); + + assertEquals(Phase.COMPLETED, session.getPhase(), + "cancel MUST NOT overwrite a COMPLETED phase"); + } +} diff --git a/src/test/java/io/icker/factions/net/ClaimSyncPayloadTest.java b/src/test/java/io/icker/factions/net/ClaimSyncPayloadTest.java new file mode 100644 index 00000000..38949370 --- /dev/null +++ b/src/test/java/io/icker/factions/net/ClaimSyncPayloadTest.java @@ -0,0 +1,31 @@ +package io.icker.factions.net; + +import io.netty.buffer.Unpooled; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.world.level.ChunkPos; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ClaimSyncPayloadTest { + @Test + void roundTrip() { + var dimA = new ClaimSyncPayload.FactionClaims.DimClaims( + "minecraft:overworld", + List.of(ChunkPos.pack(0, 0), ChunkPos.pack(1, -1))); + var dimB = new ClaimSyncPayload.FactionClaims.DimClaims( + "minecraft:the_nether", + List.of(ChunkPos.pack(5, 5))); + var faction1 = new ClaimSyncPayload.FactionClaims("Alpha", 0xFF0000FF, List.of(dimA, dimB)); + var faction2 = new ClaimSyncPayload.FactionClaims("Beta", 0xFF00FF00, List.of(dimA)); + var original = new ClaimSyncPayload(List.of(faction1, faction2)); + + var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), null); + ClaimSyncPayload.CODEC.encode(buf, original); + var decoded = ClaimSyncPayload.CODEC.decode(buf); + + assertEquals(original, decoded); + } +} diff --git a/src/test/java/io/icker/factions/ui/TradeGuiSlotsTest.java b/src/test/java/io/icker/factions/ui/TradeGuiSlotsTest.java new file mode 100644 index 00000000..7af74da7 --- /dev/null +++ b/src/test/java/io/icker/factions/ui/TradeGuiSlotsTest.java @@ -0,0 +1,100 @@ +package io.icker.factions.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the slot constants exposed by {@link TradePlacementGui} and + * {@link TradeRevealGui}. + * + *

The GUI classes extend {@code eu.pb4.sgui.api.gui.SimpleGui} and require a live + * {@link net.minecraft.server.level.ServerPlayer} to instantiate — that is covered by + * F3 QA in-game. This suite pins the {@code public static final int} constants that the + * event listener and control-slot handlers dispatch on. Per JVM §5.5 constant primitive + * field access does NOT trigger class initialisation, so this test never touches sgui. + * + *

Rationale for pinning each constant: the placement GUI ships with slots 0-17 as + * the two-row deposit grid (slot 22 CONFIRM sits in the row-3 centre, slot 24 CANCEL two + * slots right of it). The reveal GUI shrinks the deposit block to zero and moves the + * three action buttons to slots 20 / 22 / 24. Silently drifting either constant off by + * one flips a filler pane into a click target that mutates state — a duplication or + * grief vector. + */ +class TradeGuiSlotsTest { + + @Test + void placementGui_confirmSlot_is22() { + assertEquals(22, TradePlacementGui.SLOT_CONFIRM, + "SLOT_CONFIRM must remain 22 — centre of row 3, matching the slimeball icon"); + } + + @Test + void placementGui_cancelSlot_is24() { + assertEquals(24, TradePlacementGui.SLOT_CANCEL, + "SLOT_CANCEL must remain 24 — two slots right of confirm, matching the magma cream icon"); + } + + @Test + void placementGui_placementSlotCount_is18() { + assertEquals(18, TradePlacementGui.PLACEMENT_SLOT_COUNT, + "the placement grid is exactly the first two rows (slots 0-17) of a 9x3 chest"); + } + + @Test + void placementGui_confirmAndCancelSlotsAreDistinct() { + assertNotEquals(TradePlacementGui.SLOT_CONFIRM, TradePlacementGui.SLOT_CANCEL, + "confirm and cancel must NEVER collide — a single button doing both is a " + + "duplication vector"); + } + + @Test + void placementGui_controlSlotsSitOutsideThePlacementGrid() { + assertEquals(true, TradePlacementGui.SLOT_CONFIRM >= TradePlacementGui.PLACEMENT_SLOT_COUNT, + "SLOT_CONFIRM must land in row 3, past the placement grid — otherwise a " + + "click on the button would also mutate the underlying container"); + assertEquals(true, TradePlacementGui.SLOT_CANCEL >= TradePlacementGui.PLACEMENT_SLOT_COUNT, + "SLOT_CANCEL must land in row 3, past the placement grid"); + } + + @Test + void revealGui_acceptSlot_is20() { + assertEquals(20, TradeRevealGui.SLOT_ACCEPT, + "SLOT_ACCEPT must remain 20 — left of centre in row 3, matching the emerald icon"); + } + + @Test + void revealGui_negotiateSlot_is22() { + assertEquals(22, TradeRevealGui.SLOT_NEGOTIATE, + "SLOT_NEGOTIATE must remain 22 — centre of row 3, matching the anvil icon"); + } + + @Test + void revealGui_declineSlot_is24() { + assertEquals(24, TradeRevealGui.SLOT_DECLINE, + "SLOT_DECLINE must remain 24 — right of centre in row 3, matching the magma cream icon"); + } + + @Test + void revealGui_threeActionSlotsAreDistinct() { + assertNotEquals(TradeRevealGui.SLOT_ACCEPT, TradeRevealGui.SLOT_NEGOTIATE, + "accept and negotiate must NEVER collide"); + assertNotEquals(TradeRevealGui.SLOT_ACCEPT, TradeRevealGui.SLOT_DECLINE, + "accept and decline must NEVER collide"); + assertNotEquals(TradeRevealGui.SLOT_NEGOTIATE, TradeRevealGui.SLOT_DECLINE, + "negotiate and decline must NEVER collide"); + } + + @Test + void revealGui_actionSlotsAreEvenlySpaced() { + int accept = TradeRevealGui.SLOT_ACCEPT; + int negotiate = TradeRevealGui.SLOT_NEGOTIATE; + int decline = TradeRevealGui.SLOT_DECLINE; + + assertEquals(2, negotiate - accept, + "accept -> negotiate must be a 2-slot gap so a filler pane separates them"); + assertEquals(2, decline - negotiate, + "negotiate -> decline must be a 2-slot gap so a filler pane separates them"); + } +} diff --git a/src/test/java/io/icker/factions/util/WorldUtilsTest.java b/src/test/java/io/icker/factions/util/WorldUtilsTest.java new file mode 100644 index 00000000..aad30a44 --- /dev/null +++ b/src/test/java/io/icker/factions/util/WorldUtilsTest.java @@ -0,0 +1,20 @@ +package io.icker.factions.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; + +import org.junit.jupiter.api.Test; + +class WorldUtilsTest { + @Test + void chunkPositionUsesBlockCoordinatesWithoutAWorld() { + BlockPos blockPosition = new BlockPos(-1, 64, 16); + + ChunkPos chunkPosition = WorldUtils.getChunkPos(blockPosition); + + assertEquals(-1, chunkPosition.x()); + assertEquals(1, chunkPosition.z()); + } +}