diff --git a/.github/workflows/modrinth-publish.yml b/.github/workflows/modrinth-publish.yml new file mode 100644 index 0000000..461f5de --- /dev/null +++ b/.github/workflows/modrinth-publish.yml @@ -0,0 +1,132 @@ +name: Publish to Modrinth + +# Triggers when you publish a GitHub Release +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish (e.g. 0.1.1)' + required: true + type: string + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # 1. Check out the repository at the release tag + - name: Checkout repository + uses: actions/checkout@v4 + + # 2. JDK 25, not the 17 SonarCloud's wizard suggests: the Paper 26.2 and + # BentoBox dependencies ship Java 25 bytecode (class version 69), + # which older compilers cannot read. The pom still compiles + # TradeWinds' own sources at release 21. + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 25 + + # 3. Cache Maven dependencies to speed up builds + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + # 4. Build the jar - WITH tests, unlike GushBlock's copy of this + # workflow, which skips them because the patched MockBukkit it needs + # is not published anywhere. TradeWinds ships that jar inside the + # repo (libs/), so a bare checkout tests green and the artifact we + # publish is the artifact we tested. + # + # GIT_BRANCH=origin/main activates the pom's 'main' profile, which + # strips -SNAPSHOT and the build number so the jar is named + # TradeWinds-.jar. This repo releases from main, NOT master - + # point it at the wrong branch and every build stays a SNAPSHOT and + # the upload below cannot find its file. + - name: Build with Maven + run: mvn -B clean package + env: + GIT_BRANCH: origin/main + + # 5. Fail legibly if the jar is not where the upload expects it. The jar + # is named from the POM's build.version, the upload from the release + # TAG, so a tag that does not match the pom (v0.1.1, or a forgotten + # version bump) means the upload step just says "file not found". + - name: Verify the jar matches the release tag + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + jar="target/TradeWinds-${TAG}.jar" + if [ ! -f "$jar" ]; then + echo "::error::Expected $jar but the build produced:" + ls -1 target/*.jar || true + echo "::error::The release tag must match in pom.xml (no 'v' prefix)." + exit 1 + fi + echo "Publishing $jar" + + # 6. Upload the jar to Modrinth + # + # Repository secrets (Settings -> Secrets and variables -> Actions): + # + # MODRINTH_TOKEN - personal access token from + # https://modrinth.com/settings/pats + # (scope: "Create versions") + # MODRINTH_PROJECT_ID - the Modrinth project ID for TradeWinds, + # on the project page under the three-dot + # menu ("Copy ID") + # + - name: Publish to Modrinth + uses: cloudnode-pro/modrinth-publish@0be4916ad5f081d936eb5615aa35ce3f0949979c # v2 + with: + token: ${{ secrets.MODRINTH_TOKEN }} + project: ${{ secrets.MODRINTH_PROJECT_ID }} + + # The release tag, or the manually supplied tag on workflow_dispatch + version: ${{ github.event.release.tag_name || inputs.tag }} + + # The GitHub release body becomes the changelog (empty when run + # manually) + changelog: ${{ github.event.release.body }} + + # Release channel - auto-detected from the version string: + # *-alpha -> alpha, *-beta -> beta, anything else -> release + # Override here if needed: release | beta | alpha + # channel: release + + # TradeWinds is a Paper addon + loaders: |- + paper + purpur + + # The 26.x family, matching the other 26.2-era addons. TradeWinds' + # own sources compile clean against paper-api 26.1.2 as well as + # 26.2 (and, for what it is worth, against 1.21.10 - only the + # dialog API, Minecraft 1.21.6, breaks below that). + # + # The floor is NOT ours to set, though: BentoBox 3.18.1 - which + # addon.yml requires - ships Java 25 bytecode (class major 69), the + # same as paper-api 26.x, while every 1.21.x paper-api is Java 21 + # (major 65). A 1.21.x server would have to be started on a Java 25 + # JVM before BentoBox could even load, and BentoBox is built against + # the 26.x API besides. So 26.1 is the honest bottom, whatever our + # own compiler says. + # + # Only 26.2 is actually PLAYTESTED. + game-versions: |- + 26.2 + 26.1.2 + 26.1.1 + 26.1 + + # Maven produces TradeWinds-.jar in target/ (see the pom's + # finalName), and the release profile above makes the tag + files: target/TradeWinds-${{ github.event.release.tag_name || inputs.tag }}.jar diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..aa31e9b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +# TradeWinds — .github/workflows/publish.yml +# Publishes the jar attached to a GitHub release to CurseForge and Hangar via +# the shared BentoBoxWorld/.github reusable workflow. Downloads the release +# asset instead of rebuilding from source, so a dependency-repo outage cannot +# block a release that already has a built jar. The reusable workflow is pinned +# to a commit SHA (Sonar githubactions:S7637). workflow_dispatch lets you +# (re)publish a version. +# +# Modrinth is published by modrinth-publish.yml, which is a separate action and +# rebuilds from source - both fire on the same `release: published` event. +# +# CURSEFORGE_TOKEN and HANGAR_API_KEY are ORGANISATION secrets, already +# available here. The reusable workflow skips a platform whose secret is +# missing, so a lost secret is a quiet no-op rather than a failed release. + +name: Publish release to CurseForge and Hangar + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.1.1)" + required: true + type: string + +jobs: + publish: + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@1f91a0edf72e8c86d671b3b8fdd3121ac6fb88e1 # master + with: + use_release_asset: "true" # publish the jar attached to the release; do not rebuild + java_version: "25" # only a fallback if use_release_asset is turned off - Paper 26.x and BentoBox ship Java 25 bytecode + # hangar.papermc.io/BentoboxWorld/TradeWinds. hangar_owner is left at its + # default of "BentoBoxWorld": Hangar stores the namespace as + # "BentoboxWorld" (lower-case b) and every sibling project - Border, + # AcidIsland, bentobox - reports that spelling while publishing with the + # default, so the lookup is case-insensitive. Do not "fix" the casing. + hangar_slug: "TradeWinds" # blank = skip Hangar + curseforge_id: "1644572" + # The 26.x family only, matching modrinth-publish.yml - NOT the longer + # 1.21.x list the other addons publish. BentoBox 3.18.1, which addon.yml + # requires, ships Java 25 bytecode like paper-api 26.x, while every + # 1.21.x paper-api is Java 21: a 1.21.x server cannot load BentoBox at + # all without a Java 25 JVM. Only 26.2 is actually playtested. + game_versions: "26.2,26.1.2,26.1.1,26.1" + version: ${{ inputs.version }} # empty on release events -> falls back to the release tag + secrets: + HANGAR_API_KEY: ${{ secrets.HANGAR_API_KEY }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} diff --git a/pom.xml b/pom.xml index f9f8321..d7de67f 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ -LOCAL - 0.1.0 + 0.1.1 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 10007ae..4cc3af5 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -14,7 +14,6 @@ set -euo pipefail ADDONS="/Users/ben/Minecraft/26.2/plugins/BentoBox/addons" -JAR="target/TradeWinds-0.1.0-SNAPSHOT-LOCAL.jar" # Match the server jar loosely: it is named paper-26.2-87.jar, not # paper-26.2.jar, so an exact pattern silently never matched and the guard was @@ -34,5 +33,25 @@ if [ -f "$LOG" ] && [ -n "$(find "$LOG" -mmin -1 2>/dev/null)" ]; then fi mvn -q clean package "$@" + +# The jar carries the version, so never hardcode it here: a version bump used +# to break the copy (0.1.1, 2026-08-08). Take whatever package just built. +shopt -s nullglob +built=(target/TradeWinds-*-LOCAL.jar) +if [ ${#built[@]} -ne 1 ]; then + echo "Expected exactly one target/TradeWinds-*-LOCAL.jar, found ${#built[@]}." + exit 1 +fi +JAR="${built[0]}" + +# An older version left behind loads ALONGSIDE the new one - two TradeWinds +# addons over one world. Clear the deck first. +for old in "$ADDONS"/TradeWinds-*.jar; do + if [ "$(basename "$old")" != "$(basename "$JAR")" ]; then + echo "Removing stale $(basename "$old")" + rm -f "$old" + fi +done + cp "$JAR" "$ADDONS/" echo "Deployed $(basename "$JAR") to $ADDONS" diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java index 276b0f0..8caf2c9 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -20,8 +20,10 @@ * Death also drops the boat where they fell, so a respawned sailor would be * marooned ashore with no way back to their own wreck. The port lends them a * boat ({@code boats.respawn-boat}, default a bamboo raft; NONE disables) - - * but only if they have none, so keepInventory deaths and bed respawns are - * never double-boated. + * but only if they have none within reach, so keepInventory deaths and bed + * respawns are never double-boated. The loaner becomes their ACTIVE boat on + * the spot, demoting the wreck's hull to an OLD BOAT: a hull nobody owns is + * not a hold, and its owner cannot trade. * * @author tastybento */ @@ -117,18 +119,32 @@ private void grantRespawnBoat(org.bukkit.entity.Player player) { return; } Material boat = respawnBoat(); - if (boat == null || boatWithinReach(player)) { + if (boat == null || stillHasTheirBoat(player) || boatWithinReach(player)) { return; } - // They have no boat, or theirs is far away: lend a hull. If they had - // one, boarding this raft is what abandons it (with the standard - // confirmation) - the loaner itself takes nothing from them. - var hold = addon.getHoldService().active(player.getUniqueId()).isEmpty() - ? addon.getBoatService().createFor(player, boat) - : addon.getHoldManager().create(boat, null); + // The loaner is theirs the MOMENT it is handed over. An unowned hull + // is not a hold: the harbourmaster's raft sat in the pack while every + // trader turned its owner away for having no ship at the quay, and it + // only became a boat once it was placed and boarded (playtest + // 2026-08-08). Whatever they were sailing becomes their OLD BOAT - + // still charted, still carrying its cargo, still theirs to row back + // out and reclaim. + boolean replacing = addon.getHoldService().active(player.getUniqueId()).isPresent(); + var hold = addon.getBoatService().createFor(player, boat); addon.getBoatService().giveBoatItem(player, hold); - User.getInstance(player).sendMessage("tradewinds.boat.respawn-given", "[material]", - world.bentobox.tradewinds.economy.PriceEngine.prettify(boat.name())); + User.getInstance(player).sendMessage( + replacing ? "tradewinds.boat.respawn-given-replacing" : "tradewinds.boat.respawn-given", + "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(boat.name())); + } + + /** + * Whether their own boat is already in their pack - a keepInventory death, + * where the hull never left them. The loaner would demote a boat they are + * literally holding, cargo and all, so there is nothing to lend. + */ + private boolean stillHasTheirBoat(org.bukkit.entity.Player player) { + var hold = addon.getHoldService().active(player.getUniqueId()); + return hold.isPresent() && addon.getBoatService().isCarrying(player, hold.get()); } /** diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 5081e42..e805b43 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -369,7 +369,8 @@ tradewinds: merged-empty: "You shift everything across and leave the empty [material] adrift." merged-partial: "You shift across what fits - the rest stays in the [material], adrift at your feet." adrift-raft: "Adrift with no boat - the harbourmaster's [material] is in your pack." - respawn-given: "The port lends you a [material] - your own boat is adrift where you fell." + respawn-given: "The port lends you a [material] - she is yours, and your hold sails with her." + respawn-given-replacing: "The port lends you a [material] - she is yours now. Your old boat lies adrift where you fell, cargo aboard; your chart still marks her." claimed-plain: "The [material] is yours now - your hold awaits its first cargo." upgraded-plain: "You take the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." claimed: "You haul in the [material] - it is yours now, cargo and all." diff --git a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java index 43835f5..f2d57d8 100644 --- a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java @@ -50,6 +50,11 @@ public void setUp() throws Exception { when(im.getSpawnPoint(world)).thenReturn(new Location(world, 72.5, 73, 72.5)); when(mockPlayer.getWorld()).thenReturn(world); when(mockPlayer.isOnline()).thenReturn(true); + // A REAL post-respawn location on the plaza: the shared mock Location + // answers 0 to every distanceSquared, so "my boat is an ocean away" + // and "my boat is at my feet" were the same test + when(world.getName()).thenReturn("tradewinds_world"); + when(mockPlayer.getLocation()).thenReturn(new Location(world, 72, 73, 72)); // The loaner is granted a tick after the respawn: run it inline when(sch.runTask(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(Runnable.class))).thenAnswer(inv -> { @@ -134,12 +139,28 @@ void testDistantBoatStillGetsALoaner() { boat.setX(9000); boat.setZ(9000); listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); - // A loaner hull exists for them, and it did NOT displace the boat - // they still own an ocean away + // The loaner is their ACTIVE boat at once - an unowned hull is not a + // hold, and its carrier is turned away at every quay (playtest + // 2026-08-08). The distant hull becomes their OLD BOAT. + verify(boats).createFor(mockPlayer, org.bukkit.Material.BAMBOO_RAFT); verify(boats).giveBoatItem(org.mockito.ArgumentMatchers.eq(mockPlayer), org.mockito.ArgumentMatchers.any()); + } + + @Test + void testCarriedBoatMeansNoLoaner() { + // keepInventory: the hull never left their pack, so lending would + // demote a boat they are holding - cargo and all + var boat = holds.giveBoat(uuid, org.bukkit.Material.OAK_BOAT); + boat.setWorld(world.getName()); + boat.setX(9000); + boat.setZ(9000); + when(boats.isCarrying(mockPlayer, boat)).thenReturn(true); + listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); verify(boats, never()).createFor( org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(boats, never()).giveBoatItem(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); } @Test