Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/modrinth-publish.yml
Original file line number Diff line number Diff line change
@@ -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-<tag>.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 <build.version> 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-<version>.jar in target/ (see the pom's
# finalName), and the release profile above makes <version> the tag
files: target/TradeWinds-${{ github.event.release.tag_name || inputs.tag }}.jar
50 changes: 50 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<!-- Do not change unless you want different name for local builds. -->
<build.number>-LOCAL</build.number>
<!-- This allows to change between versions. -->
<build.version>0.1.0</build.version>
<build.version>0.1.1</build.version>
<!-- SonarCloud analysis (bentobox-world org). Requires the project to be
registered on SonarCloud as BentoBoxWorld_TradeWinds; the CI Sonar
step is non-fatal so an unregistered project never fails the build. -->
Expand Down
21 changes: 20 additions & 1 deletion scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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());
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/main/resources/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ tradewinds:
merged-empty: "<gold>You shift everything across and leave the empty [material] adrift.</gold>"
merged-partial: "<yellow>You shift across what fits - the rest stays in the [material], adrift at your feet.</yellow>"
adrift-raft: "<gold>Adrift with no boat - the harbourmaster's [material] is in your pack.</gold>"
respawn-given: "<gold>The port lends you a [material] - your own boat is adrift where you fell.</gold>"
respawn-given: "<gold>The port lends you a [material] - she is yours, and your hold sails with her.</gold>"
respawn-given-replacing: "<gold>The port lends you a [material] - she is yours now.</gold><gray> Your old boat lies adrift where you fell, cargo aboard; your chart still marks her.</gray>"
claimed-plain: "<gold>The [material] is yours now - your hold awaits its first cargo.</gold>"
upgraded-plain: "<gold>You take the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard.</gold>"
claimed: "<gold>You haul in the [material] - it is yours now, cargo and all.</gold>"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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
Expand Down
Loading